├── .github └── FUNDING.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── .vscode └── extensions.json ├── LICENSE ├── README.md ├── build.config.ts ├── demo ├── index.html ├── public │ └── favicon.ico └── src │ ├── App.vue │ ├── components │ ├── Joystick.vue │ └── MultiGesture.vue │ ├── data │ └── MultiGesture.ts │ ├── index.css │ └── main.ts ├── docs ├── .vitepress │ ├── components │ │ ├── Features.vue │ │ ├── Hero.vue │ │ ├── Home.vue │ │ ├── Illustration.vue │ │ ├── Layout.vue │ │ ├── Person.vue │ │ ├── PresetSection.vue │ │ └── demos │ │ │ ├── DragExample.vue │ │ │ ├── GesturesExample.vue │ │ │ ├── HoverExample.vue │ │ │ ├── MoveExample.vue │ │ │ ├── PinchExample.vue │ │ │ ├── ScrollExample.vue │ │ │ └── WheelExample.vue │ ├── config.js │ └── theme │ │ ├── index.ts │ │ └── style.css ├── demo.md ├── gesture-options.md ├── gesture-state.md ├── index.md ├── installation.md ├── introduction.md ├── motion-integration.md ├── public │ ├── banner.png │ ├── favicon.ico │ └── logo.svg ├── quick-start.md ├── roadmap.md ├── use-drag.md ├── use-gesture.md ├── use-hover.md ├── use-move.md ├── use-pinch.md ├── use-scroll.md ├── use-wheel.md ├── utilities.md └── vite.config.js ├── netlify.toml ├── package.json ├── pnpm-lock.yaml ├── scripts └── watch.ts ├── src ├── Controller.ts ├── composables │ ├── buildConfig.ts │ ├── useDrag.ts │ ├── useGesture.ts │ ├── useHover.ts │ ├── useMove.ts │ ├── usePinch.ts │ ├── useRecognizers.ts │ ├── useScroll.ts │ └── useWheel.ts ├── directives │ ├── directive.ts │ └── index.ts ├── index.ts ├── plugin │ └── index.ts ├── recognizers │ ├── CoordinatesRecognizer.ts │ ├── DistanceAngleRecognizer.ts │ ├── DragRecognizer.ts │ ├── MoveRecognizer.ts │ ├── PinchRecognizer.ts │ ├── Recognizer.ts │ ├── ScrollRecognizer.ts │ └── WheelRecognizer.ts ├── types │ ├── global.d.ts │ └── index.ts └── utils │ ├── config.ts │ ├── event.ts │ ├── math.ts │ ├── memoize-one.ts │ ├── react-fast-compare.ts │ ├── resolveOptionsWith.ts │ ├── rubberband.ts │ ├── state.ts │ └── utils.ts ├── tailwind.config.ts ├── tsconfig.json └── vite.config.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [Tahul] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Library 2 | node_modules 3 | dist 4 | 5 | # OS 6 | .DS_Store -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "semi": false, 5 | "arrowParens": "always" 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "znck.vue-language-features", 4 | "octref.vetur", 5 | "dbaeumer.vscode-eslint", 6 | "esbenp.prettier-vscode" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Yaël GUILLOUX 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🕹 @vueuse/gesture 2 | 3 | [![npm](https://img.shields.io/npm/v/@vueuse/gesture.svg)](https://www.npmjs.com/package/@vueuse/gesture) 4 | [![npm](https://img.shields.io/npm/dm/@vueuse/gesture.svg)](https://npm-stat.com/charts.html?package=@vueuse/gesture) 5 | [![Netlify Status](https://api.netlify.com/api/v1/badges/ab1db459-8420-4bc6-9fac-2bc247fa2385/deploy-status)](https://app.netlify.com/sites/vueuse-gesture/deploys) 6 | 7 | **Vue Composables** making your app **interactive** 8 | 9 | - 🚀 **Plug** & **play** 10 | - 🕹 **Mouse** & **Touch** support 11 | - 🎮 **Directives** support (**v-drag**, **v-pinch**, **v-move**...) 12 | - ✨ Written in **TypeScript** 13 | - ✅ Supports **Vue 2 & 3** using [**vue-demi**](https://github.com/antfu/vue-demi) 14 | - 🤹 Plays well with [**vueuse/motion**](https://github.com/vueuse/motion) or **any other** animation solution 15 | 16 | [🌍 **Documentation**](https://gesture.vueuse.org) 17 | 18 | [👀 **Demos**](https://vueuse-gesture-demo.netlify.app) 19 | 20 | ## Quick Start 21 | 22 | Let's **get started** quickly by **installing** the **package** and adding the **plugin**. 23 | 24 | From your **terminal**: 25 | 26 | ```bash 27 | pnpm add @vueuse/gesture 28 | ``` 29 | 30 | In your **Vue** app **entry** file: 31 | 32 | ```javascript 33 | import { createApp } from 'vue' 34 | import { GesturePlugin } from '@vueuse/gesture' 35 | import App from './App.vue' 36 | 37 | const app = createApp(App) 38 | 39 | app.use(GesturePlugin) 40 | 41 | app.mount('#app') 42 | ``` 43 | 44 | You can now **interact** with any of your **component**, **HTML** or **SVG** elements using `v-drag` or any other **directive**. 45 | 46 | ```vue 47 | 50 | 51 | 56 | ``` 57 | 58 | To see more about the **gestures** events **data**, check out [**Gesture State**](https://gesture.vueuse.org/gesture-state.html). 59 | 60 | To see more about the **gestures** options, check out [**Gesture Options**](https://gesture.vueuse.org/gesture-options.html). 61 | 62 | Also, here is a list of the available gestures: 63 | 64 | - [**Drag**](https://gesture.vueuse.org/use-drag.html) 65 | - [**Move**](https://gesture.vueuse.org/use-move.html) 66 | - [**Hover**](https://gesture.vueuse.org/use-hover.html) 67 | - [**Scroll**](https://gesture.vueuse.org/use-scroll.html) 68 | - [**Wheel**](https://gesture.vueuse.org/use-wheel.html) 69 | - [**Pinch**](https://gesture.vueuse.org/use-pinch.html) 70 | 71 | ## Credits 72 | 73 | This package is a **fork** [**react-use-gesture**](https://github.com/pmndrs/react-use-gesture) by [**pmndrs**](https://github.com/pmndrs). 74 | 75 | If you **like** this package, consider **following me** on [**GitHub**](https://github.com/Tahul) and on [**Twitter**](https://twitter.com/yaeeelglx). 76 | 77 | 👋 78 | -------------------------------------------------------------------------------- /build.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from 'unbuild' 2 | 3 | export default defineBuildConfig({ 4 | rollup: { 5 | emitCJS: true, 6 | }, 7 | declaration: true, 8 | entries: [ 9 | { 10 | input: 'src/index.ts', 11 | outDir: 'dist', 12 | name: 'index', 13 | format: 'esm', 14 | ext: 'mjs', 15 | }, 16 | { 17 | input: 'src/index.ts', 18 | outDir: 'dist', 19 | name: 'index', 20 | format: 'cjs', 21 | ext: 'cjs', 22 | }, 23 | ], 24 | externals: [ 25 | 'vue', 26 | 'csstype', 27 | '@vueuse/shared', 28 | 'framesync', 29 | 'style-value-types', 30 | '@vue/compiler-core', 31 | '@babel/parser', 32 | '@vue/shared', 33 | ], 34 | }) 35 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | Vite App 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vueuse/gesture/8b627bb2e388a0901b4f316c503b2e6516137898/demo/public/favicon.ico -------------------------------------------------------------------------------- /demo/src/App.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 34 | 35 | 40 | -------------------------------------------------------------------------------- /demo/src/components/Joystick.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 43 | -------------------------------------------------------------------------------- /demo/src/components/MultiGesture.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 86 | 87 | 106 | -------------------------------------------------------------------------------- /demo/src/data/MultiGesture.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | 'https://drscdn.500px.org/photo/126979479/w%3D440_h%3D440/v2?webp=true&v=2&sig=09ea71b0ddb91e24a59cecfb79a0189a2ab575d10372d3e8d3258e38f97a6a49', 3 | 'https://drscdn.500px.org/photo/188823103/w%3D440_h%3D440/v2?webp=true&v=3&sig=af23265ed9beaeeeb12b4f8dfed14dd613e5139495ba4a80d5dcad5cef9e39fd', 4 | 'https://drscdn.500px.org/photo/216094471/w%3D440_h%3D440/v2?webp=true&v=0&sig=16a2312302488ae2ce492fb015677ce672fcecac2befcb8d8e9944cbbfa1b53a', 5 | 'https://drscdn.500px.org/photo/227760547/w%3D440_h%3D440/v2?webp=true&v=0&sig=d00bd3de4cdc411116f82bcc4a4e8a6375ed90a686df8488088bca4b02188c73', 6 | 'https://drscdn.500px.org/photo/435236/q%3D80_m%3D1500/v2?webp=true&sig=67031bdff6f582f3e027311e2074be452203ab637c0bd21d89128844becf8e40', 7 | ] 8 | -------------------------------------------------------------------------------- /demo/src/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | @apply h-full; 3 | } 4 | 5 | body { 6 | @apply flex items-center justify-center bg-gradient-to-b from-grey-400 to-grey-600 bg-cover bg-fixed overflow-hidden; 7 | overscroll-behavior-x: none; 8 | } 9 | 10 | #app { 11 | @apply w-full md:w-8/12 xl:w-1/2 p-6; 12 | } 13 | 14 | /* PrismJS theme fix */ 15 | pre[class*='language-'] { 16 | margin: 0; 17 | border-radius: 0; 18 | box-shadow: none; 19 | } 20 | 21 | :root { 22 | --prism-scheme: dark; 23 | --prism-foreground: #d4cfbf; 24 | --prism-background: theme('colors.gray.800'); 25 | --prism-comment: #758575; 26 | --prism-string: #ce9178; 27 | --prism-literal: #4fb09d; 28 | --prism-keyword: #4d9375; 29 | --prism-function: #c2c275; 30 | --prism-deleted: #a14f55; 31 | --prism-class: #5ebaa8; 32 | --prism-builtin: #cb7676; 33 | --prism-property: #dd8e6e; 34 | --prism-namespace: #c96880; 35 | --prism-punctuation: #d4d4d4; 36 | --prism-decorator: #bd8f8f; 37 | --prism-regex: #ab5e3f; 38 | --prism-json-property: #6b8b9e; 39 | --prism-line-number: #888888; 40 | --prism-line-number-gutter: #eeeeee; 41 | --prism-line-highlight-background: #444444; 42 | --prism-selection-background: #444444; 43 | } 44 | -------------------------------------------------------------------------------- /demo/src/main.ts: -------------------------------------------------------------------------------- 1 | import { GesturePlugin } from '@vueuse/gesture' 2 | import { MotionPlugin } from '@vueuse/motion' 3 | import { createApp } from 'vue' 4 | import 'windi.css' 5 | import App from './App.vue' 6 | import './index.css' 7 | 8 | const app = createApp(App) 9 | 10 | app.use(MotionPlugin) 11 | 12 | app.use(GesturePlugin) 13 | 14 | app.mount('#app') 15 | -------------------------------------------------------------------------------- /docs/.vitepress/components/Features.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 109 | 110 | 186 | 187 | 192 | -------------------------------------------------------------------------------- /docs/.vitepress/components/Hero.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 92 | 93 | 213 | -------------------------------------------------------------------------------- /docs/.vitepress/components/Home.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 21 | 22 | 41 | -------------------------------------------------------------------------------- /docs/.vitepress/components/Layout.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 131 | 132 | 184 | -------------------------------------------------------------------------------- /docs/.vitepress/components/PresetSection.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 86 | 87 | 131 | -------------------------------------------------------------------------------- /docs/.vitepress/components/demos/DragExample.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 41 | 42 | 48 | -------------------------------------------------------------------------------- /docs/.vitepress/components/demos/GesturesExample.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /docs/.vitepress/components/demos/HoverExample.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 33 | 34 | 39 | -------------------------------------------------------------------------------- /docs/.vitepress/components/demos/MoveExample.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 67 | 68 | 79 | -------------------------------------------------------------------------------- /docs/.vitepress/components/demos/PinchExample.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 78 | -------------------------------------------------------------------------------- /docs/.vitepress/components/demos/ScrollExample.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 43 | 44 | 55 | -------------------------------------------------------------------------------- /docs/.vitepress/components/demos/WheelExample.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 48 | 49 | 54 | -------------------------------------------------------------------------------- /docs/.vitepress/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('vitepress').UserConfig} 3 | */ 4 | export default { 5 | title: '@vueuse/gesture', 6 | description: '🕹 Vue Composables making your app interactive', 7 | head: [ 8 | ['link', { rel: 'icon', href: '/favicon.ico', type: 'image/png' }], 9 | ['link', { rel: 'icon', href: '/logo.svg', type: 'image/svg+xml' }], 10 | ['meta', { name: 'author', content: 'Yaël GUILLOUX' }], 11 | ['meta', { property: 'og:title', content: '@vueuse/gesture' }], 12 | [ 13 | 'meta', 14 | { 15 | name: 'viewport', 16 | content: 17 | 'width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=0, shrink-to-fit=no', 18 | }, 19 | ], 20 | [ 21 | 'meta', 22 | { 23 | property: 'og:description', 24 | content: '🕹 Vue Composables making your app interactive', 25 | }, 26 | ], 27 | [ 28 | 'meta', 29 | { 30 | property: 'og:image', 31 | content: 'https://gesture.vueuse.org/banner.png', 32 | }, 33 | ], 34 | ['meta', { name: 'twitter:creator', content: '@yaeeelglx' }], 35 | [ 36 | 'meta', 37 | { name: 'twitter:image', content: 'https://gesture.vueuse.org/logo.svg' }, 38 | ], 39 | ['meta', { name: 'twitter:card', content: 'summary_large_image' }], 40 | ], 41 | themeConfig: { 42 | repo: 'vueuse/gesture', 43 | sidebar: [ 44 | { 45 | text: 'Getting Started', 46 | children: [ 47 | { 48 | text: 'Introduction', 49 | link: '/introduction', 50 | }, 51 | { 52 | text: 'Installation', 53 | link: '/installation', 54 | }, 55 | { 56 | text: 'Quick Start', 57 | link: '/quick-start', 58 | }, 59 | { 60 | text: 'Roadmap', 61 | link: '/roadmap', 62 | }, 63 | { 64 | text: 'Demo', 65 | link: '/demo', 66 | }, 67 | ], 68 | }, 69 | { 70 | text: 'Features', 71 | children: [ 72 | { 73 | text: 'Drag', 74 | link: '/use-drag', 75 | }, 76 | { 77 | text: 'Move', 78 | link: '/use-move', 79 | }, 80 | { 81 | text: 'Hover', 82 | link: '/use-hover', 83 | }, 84 | { 85 | text: 'Scroll', 86 | link: '/use-scroll', 87 | }, 88 | { 89 | text: 'Wheel', 90 | link: '/use-wheel', 91 | }, 92 | { 93 | text: 'Pinch', 94 | link: '/use-pinch', 95 | }, 96 | { 97 | text: 'Gestures', 98 | link: '/use-gesture', 99 | }, 100 | { 101 | text: 'Gesture state', 102 | link: '/gesture-state', 103 | }, 104 | { 105 | text: 'Gesture options', 106 | link: '/gesture-options', 107 | }, 108 | { 109 | text: 'Motion Integration', 110 | link: '/motion-integration', 111 | }, 112 | { 113 | text: 'Utilities', 114 | link: '/utilities', 115 | }, 116 | ], 117 | }, 118 | ], 119 | }, 120 | } 121 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { MotionPlugin } from '@vueuse/motion' 2 | import DefaultTheme from 'vitepress/dist/client/theme-default' 3 | import DragExample from '../components/demos/DragExample.vue' 4 | import GesturesExample from '../components/demos/GesturesExample.vue' 5 | import HoverExample from '../components/demos/HoverExample.vue' 6 | import MoveExample from '../components/demos/MoveExample.vue' 7 | import PinchExample from '../components/demos/PinchExample.vue' 8 | import ScrollExample from '../components/demos/ScrollExample.vue' 9 | import WheelExample from '../components/demos/WheelExample.vue' 10 | import Features from '../components/Features.vue' 11 | import Hero from '../components/Hero.vue' 12 | import Illustration from '../components/Illustration.vue' 13 | import Layout from '../components/Layout.vue' 14 | import './style.css' 15 | 16 | export default { 17 | ...DefaultTheme, 18 | Layout, 19 | enhanceApp({ app }) { 20 | // Plugins 21 | app.use(MotionPlugin) 22 | 23 | // Components 24 | app.component('Features', Features) 25 | app.component('Hero', Hero) 26 | app.component('Illustration', Illustration) 27 | app.component('DragExample', DragExample) 28 | app.component('MoveExample', MoveExample) 29 | app.component('PinchExample', PinchExample) 30 | app.component('ScrollExample', ScrollExample) 31 | app.component('WheelExample', WheelExample) 32 | app.component('GesturesExample', GesturesExample) 33 | app.component('HoverExample', HoverExample) 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --c-brand: #7344be; 3 | --c-brand-light: #b164e7; 4 | --bg-colored-light: #a98aff; 5 | } 6 | 7 | body, 8 | html, 9 | #app, 10 | .theme { 11 | min-height: 100%; 12 | height: 100%; 13 | } 14 | -------------------------------------------------------------------------------- /docs/demo.md: -------------------------------------------------------------------------------- 1 | # Demo 2 | 3 | I built a small page with interactive demos of the package. 4 | 5 | If you have any ideas for new ones, please let me know using [**Twitter**](https://twitter.com/yaeeelglx). 6 | 7 | [👀 **Demo**](https://vueuse-gesture-demo.netlify.app) 8 | -------------------------------------------------------------------------------- /docs/gesture-options.md: -------------------------------------------------------------------------------- 1 | # Gesture Options 2 | 3 | Every gestures can be configured with different options. 4 | 5 | Some options are generic and some are specific to gestures. 6 | 7 | Those distinctions are described on their gesture pages. 8 | 9 | ## Configuration 10 | 11 | Depending on whether you use gesture-specific hooks, [**useGesture**](/use-gesture) hook or **Directives**, you'll need to structure the config options object differently. 12 | 13 | ```vue 14 | 17 | 18 | 40 | ``` 41 | 42 | ## Generic Options 43 | 44 | ### `domTarget` 45 | 46 | Lets you specify a DOM Element or Vue Component `ref()` you want to attach the gesture to. 47 | 48 | This is pre-filled when using directives, you don't have to specify that option. 49 | 50 | - Default: `undefined` 51 | 52 | ### `eventOptions` 53 | 54 | When capture is set to true, events will be [**captured**](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events#Event_bubbling_and_capture). 55 | 56 | passive sets whether events are [**passive**](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener). 57 | 58 | - Default: `{ capture: false, passive: true }` 59 | 60 | ### `window` 61 | 62 | Lets you specify which window element the gesture should bind events to. 63 | 64 | Only relevant for the drag gesture. 65 | 66 | - Default: `window` 67 | 68 | ### `enabled` 69 | 70 | When set to false none of your handlers will be fired. 71 | 72 | - Default: `true` 73 | 74 | ### `initial` 75 | 76 | The initial position movement should start from. 77 | 78 | - Default: `[0, 0]` 79 | 80 | ### `threshold` 81 | 82 | Threshold is the minimum displacement the gesture movement needs to travel before your handler is fired. 83 | 84 | - Default: `[0, 0]` 85 | 86 | ### triggerAllEvents 87 | 88 | Forces the handler to fire even for non intentional displacement (ignores the threshold). 89 | 90 | In that case, the intentional attribute from state will remain false until the threshold is reached. 91 | 92 | ## XY Options 93 | 94 | Note that **xy gestures** refers to coordinates-based gesture: 95 | 96 | - [**Drag**](/use-drag) 97 | - [**Move**](/use-move) 98 | - [**Wheel**](/use-wheel) 99 | - [**Scroll**](/use-scroll) 100 | 101 | Additionals options specific to [**drag**](/use-drag) and [**pinch**](/use-pinch) are described on their respective pages. 102 | 103 | ### `axis` 104 | 105 | Axis makes it easy to constraint the user gesture to a specific axis. 106 | 107 | - Accepts: `x`, `y`, `undefined` 108 | 109 | - Default: `undefined` 110 | 111 | ### `lockDirection` 112 | 113 | Lock direction allows you to lock the movement of the gesture once a direction has been detected. 114 | 115 | - Default: `false` 116 | 117 | ### `bounds` 118 | 119 | Bounds option will constraint the user gesture. 120 | 121 | Both the gesture movement and offset will be clamped to the specified bounds. 122 | 123 | - Accepts: 124 | 125 | 1. `Bounds: { top?: number, bottom?: number, left?: number, right?: number }` 126 | 2. `(gestureState) => Bounds` 127 | 128 | - Default: `{ top: -Infinity, bottom: Infinity, left: -Infinity, right: Infinity }` 129 | 130 | ### `transform` 131 | 132 | When interacting with canvas objects, dealing with space coordinates that are not measured in pixels can be hard. 133 | 134 | Note that [**bounds**](#bounds) or [**initial**](#initial) values are expected to be expressed in the new space coordinates. 135 | 136 | Only [**threshold**](#thresold) always refers to screen pixel values. 137 | 138 | - Accepts: `(xy: Vector2) => Vector2` 139 | 140 | - Default: `undefined` 141 | -------------------------------------------------------------------------------- /docs/gesture-state.md: -------------------------------------------------------------------------------- 1 | # Gesture State 2 | 3 | Every time a gesture handler is called, it will get passed a gesture state that includes the source event and adds multiple attributes such as velocity, previous value, and much more. 4 | 5 | States structure doesn't vary much across different gestures, the only distinction comes from [**usePinch**](/use-pinch) and [**useDrag**](/use-drag). 6 | 7 | Those distinctions are described on their gesture pages. 8 | 9 | ## Attributes 10 | 11 | With the exception of **xy** and **vxvy**, all the attributes below are common to all gestures. 12 | 13 | ```javascript 14 | useXXXX((state) => { 15 | const { 16 | event, // the source event 17 | xy, // [x,y] values (pointer position or scroll offset) 18 | vxvy, // momentum of the gesture per axis 19 | previous, // previous xy 20 | initial, // xy value when the gesture started 21 | intentional, // is the movement intentional -- new in v8 22 | movement, // last gesture offset (xy - initial) 23 | delta, // movement delta (movement - previous movement) 24 | offset, // offset since the first gesture 25 | lastOffset, // offset when the last gesture started 26 | velocity, // absolute velocity of the gesture 27 | distance, // offset distance 28 | direction, // direction per axis 29 | startTime, // gesture start time 30 | elapsedTime, // gesture elapsed time 31 | timeStamp, // timestamp of the event 32 | first, // true when it's the first event 33 | last, // true when it's the last event 34 | active, // true when the gesture is active 35 | memo, // value returned by your handler on its previous run 36 | cancel, // function you can call to interrupt some gestures 37 | canceled, // whether the gesture was canceled (drag and pinch) 38 | down, // true when a mouse button or touch is down 39 | buttons, // number of buttons pressed 40 | touches, // number of fingers touching the screen 41 | args, // arguments you passed to bind 42 | ctrlKey, // true when control key is pressed 43 | altKey, // " " alt " " 44 | shiftKey, // " " shift " " 45 | metaKey, // " " meta " " 46 | locked, // whether document.pointerLockElement is set 47 | dragging, // is the component currently being dragged 48 | moving, // " " " moved 49 | scrolling, // " " " scrolled 50 | wheeling, // " " " wheeled 51 | pinching, // " " " pinched 52 | } = state 53 | }) 54 | ``` 55 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | home: true 3 | --- 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Install `@vueuse/gesture` using your package manager of choice. 4 | 5 | ```bash 6 | pnpm add @vueuse/gesture 7 | ``` 8 | 9 | Please note that if you are using **Vue 2** or **Nuxt**, you need to install the [**Composition API**](https://v3.vuejs.org/guide/composition-api-introduction.html). 10 | 11 | The **required** packages can be found [**here for Vue 2**](https://github.com/vuejs/composition-api), and [**here for Nuxt**](https://composition-api.nuxtjs.org/). 12 | 13 | ## Plugin Installation 14 | 15 | If you are planning on using the directives (`v-drag`, `v-move`, ...) from this package, you might want to add the plugin to your Vue instance. 16 | 17 | ### Global Installation 18 | 19 | You can add the support for directives globally, by installing the plugin. 20 | 21 | ```javascript 22 | import { GesturePlugin } from '@vueuse/gesture' 23 | 24 | const app = createApp(App) 25 | 26 | app.use(GesturePlugin) 27 | 28 | app.mount('#app') 29 | ``` 30 | 31 | ### Component Installation 32 | 33 | If you want to import the directive code only from components that uses it, import the directive and install it at component level. 34 | 35 | ```javascript 36 | import { dragDirective } from '@vueuse/gesture' 37 | 38 | export default { 39 | directives: { 40 | drag: dragDirective, 41 | }, 42 | } 43 | ``` 44 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | vueuse/gesture is a set of Vue Composables that aims at helping you making your Vue apps interactive through pointer and touch gesture support. 4 | 5 | It is a Vue port of the best gesture support library from the React ecosystem, [**react-use-gesture**](https://github.com/pmndrs/react-use-gesture) by the excellent collective, [**Poimandres**](https://github.com/pmndrs). 6 | 7 | It includes an additional layer providing Vue support using both composable functions or directives. 8 | 9 | If you use this package and encounter any problem, please submit an issue on [**GitHub**](https://github.com/vueuse/motion). 10 | 11 | If you liked this package, please follow me on [**Twitter**](https://twitter.com/yaeeelglx). 12 | 13 | 14 | 15 | Illustration from [**Pebble People**](https://blush.design/fr/collections/pebble-people) by [**Deivid Saenz**](https://blush.design/fr/artists/deivid-saenz). 16 | -------------------------------------------------------------------------------- /docs/motion-integration.md: -------------------------------------------------------------------------------- 1 | # Motion Integration 2 | 3 | Gesture support is often coupled with some spring physics animation system. 4 | 5 | It allows the gesture animations to feel smoother, and more "organic". 6 | 7 | The original library, [**react-use-gesture**](https://use-gesture.netlify.app/), uses their own solution called [**react-spring**](https://www.react-spring.io). 8 | 9 | Luckily, [**I**](https://twitter.com/yaeeelglx) also made [**@vueuse/motion**](https://motion.vueuse.org) that provides a set of composables that plays well with @vueuse/gesture. 10 | 11 | ## What is `set()` ? 12 | 13 | Looking at the code examples, you might have noticed a function called `set()`. 14 | 15 | The `set()` function comes from the [**useSpring**](https://motion.vueuse.org/api/use-spring.html) composable from [**@vueuse/motion**](https://motion.vueuse.org). 16 | 17 | [**useSpring**](https://motion.vueuse.org/api/use-spring.html) used with [**useMotionProperties**](https://motion.vueuse.org/api/use-motion-properties.html) is a powerful combination when it comes to animate elements. 18 | 19 | ```javascript 20 | // Get the element. 21 | const demoElement = ref() 22 | 23 | // Bind to the element or component reference 24 | // and init style properties that will be animated. 25 | const { motionProperties } = useMotionProperties(demoElement, { 26 | cursor: 'grab', 27 | x: 0, 28 | y: 0, 29 | }) 30 | 31 | // Bind the motion properties to a spring reactive object. 32 | const { set } = useSpring(motionProperties) 33 | 34 | // Animatable values will be animated, the others will be changed immediately. 35 | const eventHandler = () => set({ x: 250, y: 200, cursor: 'default' }) 36 | ``` 37 | 38 | ## Directives support 39 | 40 | Using [**useSpring**](https://motion.vueuse.org/api/use-spring.html) with [**useMotionProperties**](https://motion.vueuse.org/api/use-motion-properties.html) allows low level data manipulations. 41 | 42 | If you are already using [**@vueuse/motion**](https://motion.vueuse.org), you might be familiar with [**Directive Usage**](https://motion.vueuse.org/directive-usage.html). 43 | 44 | Here is a nice example of combining `v-motion` with `v-drag`: 45 | 46 | ```vue 47 | 53 | 54 | 82 | ``` 83 | -------------------------------------------------------------------------------- /docs/public/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vueuse/gesture/8b627bb2e388a0901b4f316c503b2e6516137898/docs/public/banner.png -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vueuse/gesture/8b627bb2e388a0901b4f316c503b2e6516137898/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | Let's get started quickly by installing the package and adding the plugin. 4 | 5 | From your terminal: 6 | 7 | ```bash 8 | pnpm add @vueuse/gesture 9 | ``` 10 | 11 | In your Vue app entry file: 12 | 13 | ```javascript 14 | import { createApp } from 'vue' 15 | import { GesturePlugin } from '@vueuse/gesture' 16 | import App from './App.vue' 17 | 18 | const app = createApp(App) 19 | 20 | app.use(GesturePlugin) 21 | 22 | app.mount('#app') 23 | ``` 24 | 25 | You can now interact with any of your component, **HTML** or **SVG** elements using `v-drag` or any other directive. 26 | 27 | ```vue 28 | 31 | 32 | 37 | ``` 38 | 39 | To see more about the gestures event data, check out [**Gesture State**](/gesture-state). 40 | 41 | To see more about the drag gesture, check out [**useDrag**](/use-drag) page. 42 | -------------------------------------------------------------------------------- /docs/roadmap.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | This package is under active development, as it still misses some features. 4 | 5 | As I think it is still more than usable in its current state, I'm releasing it. 6 | 7 | - ✔️ : Done 8 | - 👨‍💻 : WIP 9 | - ❓ : Ideas 10 | 11 | The **roadmap** includes: 12 | 13 | - ✔️ **Finishing documenting the whole API** 14 | - 👨‍💻 **Decent test suite** 15 | - 👨‍💻 **Better demos and examples** 16 | - ❓ **v-gesture supporting multiple events** 17 | -------------------------------------------------------------------------------- /docs/use-drag.md: -------------------------------------------------------------------------------- 1 | # Drag 2 | 3 | Drag the box. 4 | 5 | 6 | 7 | ```vue 8 | 12 | 13 | 36 | ``` 37 | 38 | ## State 39 | 40 | In addition to regular [**Gesture Options**](/gesture-options), the drag gesture adds few attributes. 41 | 42 | ```javascript 43 | useDrag(({ swipe, tap }) => doSomething(swipe, tap)) 44 | ``` 45 | 46 | ### `swipe` 47 | 48 | Swipe is a convenient state attribute for the gesture that will help you detect swipes. 49 | 50 | Swipe is a vector which both components are either -1, 0 or 1. 51 | 52 | The component stays to 0 until a swipe is detected. 53 | 54 | 1 or -1 indicates the direction of the swipe. 55 | 56 | Left or right on the horizontal axis, top or bottom on the vertical axis. 57 | 58 | ### `tap` 59 | 60 | Tap is a boolean for the gesture that will be true if the gesture can be assimilated to a tap or click. 61 | 62 | Usually tap is used with the [**filterTaps**](#filterTaps) option. 63 | 64 | ## Options 65 | 66 | ### `filterTaps` 67 | 68 | If true, the component won't trigger your drag logic if the user just clicked on the component. 69 | 70 | ### `preventWindowScrollY` 71 | 72 | If true, drag will be triggered after 250ms and will prevent window scrolling. 73 | 74 | ### `useTouch` 75 | 76 | Most gestures, drag included, use pointer events. 77 | 78 | This works well 99% of the time, but pointer events get canceled on touch devices when the user starts scrolling. 79 | 80 | Usually this is what you actually want, and the browser does it for you. 81 | 82 | But in some situations you may want the drag to persist while scrolling. 83 | 84 | In that case you'll need to indicate @vueuse/gesturee to use touch events, which aren't canceled on scroll. 85 | 86 | ### `delay` 87 | 88 | If set, the handler will be delayed for the duration of the delay (in ms) — or if the user starts moving. 89 | 90 | When set to true, delay is defaulted to 180ms. 91 | 92 | Note: delay and threshold don't play well together (without moving your pointer, your handler will never get triggered). 93 | 94 | ### `swipeDistance` 95 | 96 | The minimum distance per axis (in pixels) the gesture needs to travel to trigger a swipe. 97 | 98 | ### `swipeVelocity` 99 | 100 | The minimum velocity per axis (in pixels / ms) the gesture needs to reach before the pointer is released. 101 | 102 | ### `swipeDuration` 103 | 104 | The maximum duration in milliseconds that a swipe is detected. 105 | -------------------------------------------------------------------------------- /docs/use-gesture.md: -------------------------------------------------------------------------------- 1 | # Gestures 2 | 3 | useGesture is a composable that allows you to manage different gestures at once. 4 | 5 | ```javascript 6 | useGesture( 7 | { 8 | onDrag: (state) => doSomething(state), 9 | onDragStart: (state) => doSomething(state), 10 | onDragEnd: (state) => doSomething(state), 11 | onPinch: (state) => doSomething(state), 12 | onPinchStart: (state) => doSomething(state), 13 | onPinchEnd: (state) => doSomething(state), 14 | onScroll: (state) => doSomething(state), 15 | onScrollStart: (state) => doSomething(state), 16 | onScrollEnd: (state) => doSomething(state), 17 | onMove: (state) => doSomething(state), 18 | onMoveStart: (state) => doSomething(state), 19 | onMoveEnd: (state) => doSomething(state), 20 | onWheel: (state) => doSomething(state), 21 | onWheelStart: (state) => doSomething(state), 22 | onWheelEnd: (state) => doSomething(state), 23 | onHover: (state) => doSomething(state), 24 | }, 25 | config, 26 | ) 27 | ``` 28 | 29 | ## Configuration 30 | 31 | As described on [**Gesture Options**](/gesture-options) page, useGesture config allows you to define options for each type of event. 32 | 33 | ```javascript 34 | // When you use the useGesture hook 35 | useGesture((state) => doSomething(state), { 36 | // Global options such as `domTarget` 37 | ...genericOptions, 38 | // Gesture specific options 39 | drag: dragOptions, 40 | wheel: wheelOptions, 41 | pinch: pinchOptions, 42 | scroll: scrollOptions, 43 | wheel: wheelOptions, 44 | hover: hoverOptions, 45 | }) 46 | ``` 47 | 48 | ## Example 49 | 50 | A nice usage example can be found [**here**](https://vueuse-gesture-demo.netlify.app). 51 | 52 | The code from this example is [**here**](https://github.com/vueuse/gesture/blob/main/demo/src/components/MultiGesture.vue). 53 | -------------------------------------------------------------------------------- /docs/use-hover.md: -------------------------------------------------------------------------------- 1 | # Hover 2 | 3 | Hover the box. 4 | 5 | 6 | 7 | ```vue 8 | 12 | 13 | 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/use-move.md: -------------------------------------------------------------------------------- 1 | # Move 2 | 3 | Move inside the box. 4 | 5 | 6 | 7 | ```vue 8 | 12 | 13 | 34 | ``` 35 | -------------------------------------------------------------------------------- /docs/use-pinch.md: -------------------------------------------------------------------------------- 1 | # Pinch 2 | 3 | Pinch the box. 4 | 5 | Works best on touch devices, or using a laptop trackpad. 6 | 7 | 8 | 9 | ```vue 10 | 14 | 15 | 32 | ``` 33 | 34 | ## State 35 | 36 | In addition to regular [**Gesture Options**](/gesture-options), the pinch gesture adds few attributes. 37 | 38 | ```javascript 39 | usePinch((state) => { 40 | const { 41 | da, // [d,a] absolute distance and angle of the two pointers 42 | vdva, // momentum of the gesture of distance and rotation 43 | origin, // coordinates of the center between the two touch event 44 | } = state 45 | }) 46 | ``` 47 | 48 | ## Options 49 | 50 | ### `distanceBounds` 51 | 52 | Limits the distance for `movement` and `offset` to the specified bounds. 53 | 54 | ### `angleBounds` 55 | 56 | Limits the angle for `movement` and `offset` to the specified bounds. 57 | -------------------------------------------------------------------------------- /docs/use-scroll.md: -------------------------------------------------------------------------------- 1 | # Scroll 2 | 3 | Scroll the box. 4 | 5 | 6 | 7 | ```vue 8 | 12 | 13 | 29 | ``` 30 | -------------------------------------------------------------------------------- /docs/use-wheel.md: -------------------------------------------------------------------------------- 1 | # Wheel 2 | 3 | Use your mouse wheel or trackpad. 4 | 5 | Both axis and velocity are supported. 6 | 7 | 8 | 9 | ```vue 10 | 14 | 15 | 41 | ``` 42 | 43 | ## Specificities 44 | 45 | Mouse devices such as the Macbook trackpad, or the Magic Mouse have inertia 46 | 47 | There is no native way to distinguish between an actual wheel intent and its resulting inertia. 48 | 49 | To detect intent, you can use [**Lethargy**](https://github.com/d4nyll/lethargy). 50 | -------------------------------------------------------------------------------- /docs/utilities.md: -------------------------------------------------------------------------------- 1 | # Utilities 2 | 3 | @vueuse/gesture uses a set of utility functions internally that are exposed for anybody's convenience. 4 | 5 | ## addV 6 | 7 | Adds two vectors. 8 | 9 | ```javascript 10 | import { addV } from '@vueuse/gesture' 11 | 12 | addV([10, 5], [5, 7]) // Returns [15, 12] 13 | ``` 14 | 15 | ## subV 16 | 17 | Substracts two vectors. 18 | 19 | ```javascript 20 | import { subV } from '@vueuse/gesture' 21 | 22 | subV([10, 5], [1, 2]) // Returns [9, 3] 23 | ``` 24 | 25 | ## rubberbandIfOutOfBounds 26 | 27 | Calculates the rubberbanding effect from a given position value, two bounds min, max and an elasticity constant. 28 | 29 | ```typescript 30 | import { rubberbandIfOutOfBounds } from '@vueuse/gesture' 31 | 32 | rubberbandIfOutOfBounds( 33 | position: number, 34 | min: number, 35 | max: number, 36 | constant = 0.15, 37 | ) 38 | ``` 39 | -------------------------------------------------------------------------------- /docs/vite.config.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import { fileURLToPath } from 'url' 3 | import { defineConfig } from 'vite' 4 | 5 | export default defineConfig({ 6 | optimizeDeps: { 7 | exclude: ['vue-demi'], 8 | }, 9 | resolve: { 10 | alias: [ 11 | { 12 | find: '@vueuse/gesture', 13 | replacement: resolve( 14 | fileURLToPath(import.meta.url), 15 | '../../src/index.ts', 16 | ), 17 | }, 18 | ], 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "docs/.vitepress/dist" 3 | command = "npx pnpm install --store=node_modules/.pnpm-store && npx pnpm build:docs" 4 | 5 | [build.environment] 6 | NODE_VERSION = "18" 7 | NPM_FLAGS = "--version" # prevent Netlify npm install 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vueuse/gesture", 3 | "version": "2.0.0", 4 | "description": "🕹 Vue Composables making your app interactive", 5 | "repository": "https://github.com/vueuse/gesture", 6 | "bugs": { 7 | "url": "https://github.com/vueuse/gesture/issues" 8 | }, 9 | "homepage": "https://github.com/vueuse/gesture#readme", 10 | "author": "Yaël GUILLOUX ", 11 | "license": "MIT", 12 | "keywords": [ 13 | "vue", 14 | "hook", 15 | "gesture", 16 | "motion", 17 | "v-gesture", 18 | "use-gesture-vue", 19 | "interactive-vue" 20 | ], 21 | "type": "module", 22 | "main": "dist/index.cjs", 23 | "module": "dist/index.mjs", 24 | "types": "dist/index.d.ts", 25 | "exports": { 26 | ".": { 27 | "import": "./dist/index.mjs", 28 | "require": "./dist/index.cjs" 29 | } 30 | }, 31 | "scripts": { 32 | "build": "unbuild", 33 | "dev": "jiti scripts/watch.ts --cache", 34 | "lint": "prettier -c --parser typescript \"{src,__tests__,e2e}/**/*.[jt]s?(x)\"", 35 | "lint:fix": "pnpm lint --write", 36 | "test:types": "tsc --build tsconfig.json", 37 | "test": "pnpm test:types", 38 | "dev:docs": "vitepress dev docs", 39 | "build:docs": "vitepress build docs", 40 | "serve:docs": "vitepress serve docs", 41 | "dev:demo": "vite", 42 | "build:demo": "vite build", 43 | "serve:demo": "vite serve demo/dist", 44 | "release": "release-it" 45 | }, 46 | "files": [ 47 | "dist/**/*", 48 | "LICENSE", 49 | "README.md" 50 | ], 51 | "dependencies": { 52 | "chokidar": "^3.6.0", 53 | "consola": "^3.2.3", 54 | "upath": "^2.0.1", 55 | "vue-demi": "*" 56 | }, 57 | "peerDependencies": { 58 | "@vue/composition-api": "^1.4.1", 59 | "vue": "^2.0.0 || >=3.0.0-rc.0" 60 | }, 61 | "peerDependenciesMeta": { 62 | "@vue/composition-api": { 63 | "optional": true 64 | } 65 | }, 66 | "devDependencies": { 67 | "@vitejs/plugin-vue": "^5.0.4", 68 | "@vue/compiler-sfc": "^3.4.19", 69 | "@vue/server-renderer": "^3.4.19", 70 | "@vue/test-utils": "^2.4.4", 71 | "@vuedx/typecheck": "^0.7.6", 72 | "@vuedx/typescript-plugin-vue": "^0.7.6", 73 | "@vueuse/motion": "^2.1.0", 74 | "jiti": "^1.21.0", 75 | "lint-staged": "^15.2.2", 76 | "pascalcase": "^2.0.0", 77 | "patch-vue-directive-ssr": "^0.0.1", 78 | "popmotion": "^11.0.5", 79 | "prettier": "^3.2.5", 80 | "ts-jest": "^29.1.2", 81 | "typescript": "^5.3.3", 82 | "unbuild": "^2.0.0", 83 | "vite": "^5.1.4", 84 | "vite-plugin-windicss": "^1.9.3", 85 | "vitepress": "^0.22.4", 86 | "vue": "^3.4.19", 87 | "yorkie": "^2.0.0" 88 | }, 89 | "gitHooks": { 90 | "pre-commit": "lint-staged" 91 | }, 92 | "lint-staged": { 93 | "*.js": [ 94 | "prettier --write" 95 | ], 96 | "*.ts?(x)": [ 97 | "prettier --parser=typescript --write" 98 | ] 99 | }, 100 | "release-it": { 101 | "hooks": { 102 | "before:init": [ 103 | "pnpm build" 104 | ] 105 | }, 106 | "npm": { 107 | "access": "public" 108 | }, 109 | "git": { 110 | "commitMessage": "chore(release): release v${version}" 111 | }, 112 | "github": { 113 | "release": true, 114 | "releaseName": "v${version}" 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /scripts/watch.ts: -------------------------------------------------------------------------------- 1 | import { watch } from 'chokidar' 2 | import { build } from 'unbuild' 3 | import { resolve } from 'upath' 4 | import consola from 'consola' 5 | 6 | // Package root 7 | const rootDir = resolve(__dirname, '..') 8 | 9 | // Package src 10 | const src = resolve(__dirname, '../src') 11 | 12 | // Package build promise 13 | const tryBuild = async () => { 14 | try { 15 | await build(rootDir, false) 16 | } catch (e) { 17 | consola.log(e) 18 | } finally { 19 | consola.info('Waiting for changes...') 20 | } 21 | } 22 | 23 | // Watch src, rebuild on any change 24 | const watcher = watch(src, { 25 | ignoreInitial: true, 26 | }) 27 | 28 | watcher.on('change', tryBuild) 29 | 30 | watcher.on('add', tryBuild) 31 | 32 | watcher.on('unlink', tryBuild) 33 | 34 | tryBuild() 35 | -------------------------------------------------------------------------------- /src/Controller.ts: -------------------------------------------------------------------------------- 1 | import { unref } from 'vue-demi' 2 | import { 3 | EventHandlers, 4 | EventHandlersKey, 5 | Fn, 6 | GestureTarget, 7 | InternalConfig, 8 | InternalHandlers, 9 | RecognizerClass, 10 | State, 11 | StateKey, 12 | } from './types' 13 | import { 14 | getTouchIds, 15 | supportsGestureEvents, 16 | supportsTouchEvents, 17 | } from './utils/event' 18 | import { getInitialState } from './utils/state' 19 | import { chainFns } from './utils/utils' 20 | 21 | /** 22 | * The controller will keep track of the state for all gestures and also keep 23 | * track of timeouts, and window listeners. 24 | */ 25 | export default class Controller { 26 | public nativeRefs!: any 27 | public config!: InternalConfig 28 | public handlers!: InternalHandlers 29 | public state: State // state for all gestures 30 | public timeouts: { [stateKey in StateKey]?: number } // tracks timeouts of debounced gestures 31 | public domListeners: [string, Fn][] // when config.domTarget is set, we attach events directly to the dom 32 | public windowListeners: { [stateKey in StateKey]?: [string, Function][] } // keeps track of window listeners added by gestures (drag only at the moment) 33 | 34 | public pointerIds = new Set() // register Pointer Events pointerIds 35 | public touchIds = new Set() // register Touch Events identifiers 36 | public supportsTouchEvents = supportsTouchEvents() 37 | public supportsGestureEvents = supportsGestureEvents() 38 | 39 | constructor(private classes: Set) { 40 | this.classes = classes 41 | this.state = getInitialState() 42 | this.timeouts = {} 43 | this.domListeners = [] 44 | this.windowListeners = {} 45 | } 46 | 47 | public bind = (...args: any[]) => { 48 | const bindings: { [key: string]: Function[] } = {} 49 | 50 | for (let RecognizerClass of this.classes) 51 | new RecognizerClass(this, args).addBindings(bindings) 52 | 53 | // // we also add event bindings for native handlers 54 | for (let eventKey in this.nativeRefs) { 55 | addBindings(bindings, eventKey, (event: any) => 56 | this.nativeRefs[eventKey]({ ...this.state.shared, event, args }), 57 | ) 58 | } 59 | 60 | if (this.config.domTarget) { 61 | // If config.domTarget is set we add event listeners to it and return the clean function. 62 | return updateDomListeners(this, bindings) 63 | } else { 64 | // If not, we return an object that contains gesture handlers mapped to react handler event keys. 65 | return getPropsListener(this, bindings) 66 | } 67 | } 68 | 69 | /** 70 | * Function ran on component unmount: cleans timeouts and removes dom listeners set by the bind function. 71 | */ 72 | public clean = (): void => { 73 | const { eventOptions, domTarget } = this.config 74 | 75 | const _target = unref(domTarget) as GestureTarget 76 | 77 | if (_target) 78 | removeListeners(_target, takeAll(this.domListeners), eventOptions) 79 | 80 | Object.values(this.timeouts).forEach(clearTimeout) 81 | 82 | clearAllWindowListeners(this) 83 | } 84 | 85 | /** 86 | * Resets state to the initial value. 87 | */ 88 | public reset = (): void => { 89 | this.state = getInitialState() 90 | } 91 | } 92 | 93 | export function addEventIds( 94 | controller: Controller, 95 | event: TouchEvent | PointerEvent, 96 | ) { 97 | if ('pointerId' in event) { 98 | controller.pointerIds.add(event.pointerId) 99 | } else { 100 | controller.touchIds = new Set(getTouchIds(event)) 101 | } 102 | } 103 | 104 | export function removeEventIds( 105 | controller: Controller, 106 | event: TouchEvent | PointerEvent, 107 | ) { 108 | if ('pointerId' in event) { 109 | controller.pointerIds.delete(event.pointerId) 110 | } else { 111 | getTouchIds(event).forEach((id) => controller.touchIds.delete(id)) 112 | } 113 | } 114 | 115 | export function clearAllWindowListeners(controller: Controller) { 116 | const { 117 | config: { window: el, eventOptions }, 118 | windowListeners, 119 | } = controller 120 | const _el = unref(el) as GestureTarget 121 | 122 | if (!_el) return 123 | 124 | for (let stateKey in windowListeners) { 125 | const handlers = windowListeners[stateKey as StateKey] 126 | removeListeners(_el, handlers, eventOptions) 127 | } 128 | 129 | controller.windowListeners = {} 130 | } 131 | 132 | export function clearWindowListeners( 133 | { config, windowListeners }: Controller, 134 | stateKey: StateKey, 135 | options = config.eventOptions, 136 | ) { 137 | const _window = unref(config.window) as GestureTarget 138 | 139 | if (!_window) return 140 | 141 | removeListeners(_window, windowListeners[stateKey], options) 142 | 143 | delete windowListeners[stateKey] 144 | } 145 | 146 | export function updateWindowListeners( 147 | { config, windowListeners }: Controller, 148 | stateKey: StateKey, 149 | listeners: [string, Fn][] = [], 150 | options = config.eventOptions, 151 | ) { 152 | const _window = unref(config.window) as GestureTarget 153 | 154 | if (!_window) return 155 | 156 | removeListeners(_window, windowListeners[stateKey], options) 157 | 158 | addListeners(_window, (windowListeners[stateKey] = listeners), options) 159 | } 160 | 161 | function updateDomListeners( 162 | { config, domListeners }: Controller, 163 | bindings: { [key: string]: Function[] }, 164 | ) { 165 | const { eventOptions, domTarget } = config 166 | 167 | const _target = unref(domTarget) as GestureTarget 168 | 169 | if (!_target) throw new Error('domTarget must be defined') 170 | 171 | removeListeners(_target, takeAll(domListeners), eventOptions) 172 | 173 | for (let [key, fns] of Object.entries(bindings)) { 174 | const name = key.slice(2).toLowerCase() 175 | domListeners.push([name, chainFns(...fns)]) 176 | } 177 | 178 | addListeners(_target, domListeners, eventOptions) 179 | } 180 | 181 | function getPropsListener( 182 | { config }: Controller, 183 | bindings: { [key: string]: Function[] }, 184 | ) { 185 | const props: EventHandlers = {} 186 | const captureString = config.eventOptions.capture ? 'Capture' : '' 187 | for (let [event, fns] of Object.entries(bindings)) { 188 | const fnsArray = Array.isArray(fns) ? fns : [fns] 189 | const key = (event + captureString) as EventHandlersKey 190 | props[key] = chainFns(...(fnsArray as Fn[])) 191 | } 192 | return props 193 | } 194 | 195 | function takeAll(array: Array = []) { 196 | return array.splice(0, array.length) 197 | } 198 | 199 | /** 200 | * bindings is an object which keys match EventHandlersKeys. 201 | * Since a recognizer might want to bind a handler function to an event key already used by a previously 202 | * added recognizer, we need to make sure that each event key is an array of all the functions mapped for 203 | * that key. 204 | */ 205 | export function addBindings(bindings: any, name: string, fn: Fn): void { 206 | if (!bindings[name]) bindings[name] = [] 207 | bindings[name]!.push(fn) 208 | } 209 | 210 | function addListeners( 211 | el: GestureTarget, 212 | listeners: Array<[string, Fn]> = [], 213 | options = {}, 214 | ) { 215 | if (!el) return 216 | 217 | for (let [eventName, eventHandler] of listeners) { 218 | el.addEventListener(eventName, eventHandler, options) 219 | } 220 | } 221 | 222 | function removeListeners( 223 | el: GestureTarget, 224 | listeners: Array<[string, Fn]> = [], 225 | options = {}, 226 | ) { 227 | if (!el) return 228 | 229 | for (let [eventName, eventHandler] of listeners) { 230 | el.removeEventListener(eventName, eventHandler, options) 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/composables/buildConfig.ts: -------------------------------------------------------------------------------- 1 | import { 2 | InternalConfig, 3 | UseDragConfig, 4 | UseGestureConfig, 5 | UseHoverConfig, 6 | UseMoveConfig, 7 | UsePinchConfig, 8 | UseScrollConfig, 9 | UseWheelConfig, 10 | } from '../types' 11 | import { 12 | getInternalCoordinatesOptions, 13 | getInternalDistanceAngleOptions, 14 | getInternalDragOptions, 15 | getInternalGenericOptions, 16 | } from '../utils/config' 17 | 18 | export function _buildMoveConfig({ 19 | domTarget, 20 | eventOptions, 21 | window, 22 | enabled, 23 | ...rest 24 | }: UseMoveConfig) { 25 | const opts: InternalConfig = getInternalGenericOptions({ 26 | domTarget, 27 | eventOptions, 28 | window, 29 | enabled, 30 | }) 31 | opts.move = getInternalCoordinatesOptions(rest) 32 | return opts 33 | } 34 | 35 | export function _buildHoverConfig({ 36 | domTarget, 37 | eventOptions, 38 | window, 39 | enabled, 40 | ...rest 41 | }: UseHoverConfig) { 42 | const opts: InternalConfig = getInternalGenericOptions({ 43 | domTarget, 44 | eventOptions, 45 | window, 46 | enabled, 47 | }) 48 | opts.hover = { enabled: true, ...rest } 49 | return opts 50 | } 51 | 52 | export function _buildDragConfig({ 53 | domTarget, 54 | eventOptions, 55 | window, 56 | enabled, 57 | ...rest 58 | }: UseDragConfig) { 59 | const opts: InternalConfig = getInternalGenericOptions({ 60 | domTarget, 61 | eventOptions, 62 | window, 63 | enabled, 64 | }) 65 | opts.drag = getInternalDragOptions(rest) 66 | return opts 67 | } 68 | 69 | export function _buildPinchConfig({ 70 | domTarget, 71 | eventOptions, 72 | window, 73 | enabled, 74 | ...rest 75 | }: UsePinchConfig) { 76 | const opts: InternalConfig = getInternalGenericOptions({ 77 | domTarget, 78 | eventOptions, 79 | window, 80 | enabled, 81 | }) 82 | opts.pinch = getInternalDistanceAngleOptions(rest) 83 | return opts 84 | } 85 | 86 | export function _buildScrollConfig({ 87 | domTarget, 88 | eventOptions, 89 | window, 90 | enabled, 91 | ...rest 92 | }: UseScrollConfig) { 93 | const opts: InternalConfig = getInternalGenericOptions({ 94 | domTarget, 95 | eventOptions, 96 | window, 97 | enabled, 98 | }) 99 | opts.scroll = getInternalCoordinatesOptions(rest) 100 | return opts 101 | } 102 | 103 | export function _buildWheelConfig({ 104 | domTarget, 105 | eventOptions, 106 | window, 107 | enabled, 108 | ...rest 109 | }: UseWheelConfig) { 110 | const opts: InternalConfig = getInternalGenericOptions({ 111 | domTarget, 112 | eventOptions, 113 | window, 114 | enabled, 115 | }) 116 | opts.wheel = getInternalCoordinatesOptions(rest) 117 | return opts 118 | } 119 | 120 | export function buildComplexConfig( 121 | config: UseGestureConfig, 122 | actions: Set = new Set(), 123 | ) { 124 | const { 125 | drag, 126 | wheel, 127 | move, 128 | scroll, 129 | pinch, 130 | hover, 131 | eventOptions, 132 | window, 133 | transform, 134 | domTarget, 135 | enabled, 136 | } = config 137 | 138 | const mergedConfig: InternalConfig = getInternalGenericOptions({ 139 | domTarget, 140 | eventOptions, 141 | transform, 142 | window, 143 | enabled, 144 | }) 145 | 146 | if (actions.has('onDrag')) mergedConfig.drag = getInternalDragOptions(drag) 147 | if (actions.has('onWheel')) 148 | mergedConfig.wheel = getInternalCoordinatesOptions(wheel) 149 | if (actions.has('onScroll')) 150 | mergedConfig.scroll = getInternalCoordinatesOptions(scroll) 151 | if (actions.has('onMove')) 152 | mergedConfig.move = getInternalCoordinatesOptions(move) 153 | if (actions.has('onPinch')) 154 | mergedConfig.pinch = getInternalDistanceAngleOptions(pinch) 155 | if (actions.has('onHover')) mergedConfig.hover = { enabled: true, ...hover } 156 | 157 | return mergedConfig 158 | } 159 | -------------------------------------------------------------------------------- /src/composables/useDrag.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue-demi' 2 | import { DragRecognizer } from '../recognizers/DragRecognizer' 3 | import { RecognizersMap } from '../recognizers/Recognizer' 4 | import { EventTypes, Handler, UseDragConfig } from '../types' 5 | import memoize from '../utils/memoize-one' 6 | import isEqual from '../utils/react-fast-compare' 7 | import { _buildDragConfig } from './buildConfig' 8 | import useRecognizers from './useRecognizers' 9 | 10 | /** 11 | * Drag hook. 12 | * 13 | * @param handler - the function fired every time the drag gesture updates 14 | * @param [config={}] - the config object including generic options and drag options 15 | */ 16 | export function useDrag( 17 | handler: Handler<'drag', K>, 18 | config: UseDragConfig | {} = {}, 19 | ) { 20 | RecognizersMap.set('drag', DragRecognizer) 21 | 22 | const buildDragConfig = ref() 23 | 24 | if (!buildDragConfig.value) { 25 | buildDragConfig.value = memoize(_buildDragConfig, isEqual) 26 | } 27 | 28 | return useRecognizers({ drag: handler }, buildDragConfig.value(config)) 29 | } 30 | -------------------------------------------------------------------------------- /src/composables/useGesture.ts: -------------------------------------------------------------------------------- 1 | import { DragRecognizer } from '../recognizers/DragRecognizer' 2 | import { MoveRecognizer } from '../recognizers/MoveRecognizer' 3 | import { PinchRecognizer } from '../recognizers/PinchRecognizer' 4 | import { RecognizersMap } from '../recognizers/Recognizer' 5 | import { ScrollRecognizer } from '../recognizers/ScrollRecognizer' 6 | import { WheelRecognizer } from '../recognizers/WheelRecognizer' 7 | import { 8 | AnyGestureEventTypes, 9 | EventTypes, 10 | Handlers, 11 | InternalConfig, 12 | InternalHandlers, 13 | UseGestureConfig, 14 | UserHandlers, 15 | } from '../types' 16 | import { buildComplexConfig } from './buildConfig' 17 | import useRecognizers from './useRecognizers' 18 | 19 | export function wrapStart(fn: Function) { 20 | return function (this: any, { first }: any) { 21 | if (first) fn.apply(this, arguments) 22 | } 23 | } 24 | 25 | export function wrapEnd(fn: Function) { 26 | return function (this: any, { last }: any) { 27 | if (last) fn.apply(this, arguments) 28 | } 29 | } 30 | 31 | const RE_NOT_NATIVE = /^on(Drag|Wheel|Scroll|Move|Pinch|Hover)/ 32 | 33 | function sortHandlers(handlers: object) { 34 | const native: any = {} 35 | const handle: any = {} 36 | const actions = new Set() 37 | 38 | for (let key in handlers) { 39 | if (RE_NOT_NATIVE.test(key)) { 40 | actions.add(RegExp.lastMatch) 41 | handle[key] = (handlers as any)[key] 42 | } else { 43 | native[key] = (handlers as any)[key] 44 | } 45 | } 46 | 47 | return [handle, native, actions] 48 | } 49 | 50 | /** 51 | * @public 52 | * 53 | * The most complete gesture hook, allowing support for multiple gestures. 54 | * 55 | * @param {Handlers} handlers - an object with on[Gesture] keys containg gesture handlers 56 | * @param {UseGestureConfig} [config={}] - the full config object 57 | * @returns {(...args: any[]) => HookReturnType} 58 | */ 59 | export function useGesture( 60 | _handlers: Handlers, 61 | config: UseGestureConfig, 62 | ) { 63 | const [handlers, nativeHandlers, actions] = sortHandlers(_handlers) 64 | 65 | RecognizersMap.set('drag', DragRecognizer) 66 | RecognizersMap.set('hover', MoveRecognizer) 67 | RecognizersMap.set('move', MoveRecognizer) 68 | RecognizersMap.set('pinch', PinchRecognizer) 69 | RecognizersMap.set('scroll', ScrollRecognizer) 70 | RecognizersMap.set('wheel', WheelRecognizer) 71 | 72 | const mergedConfig: InternalConfig = buildComplexConfig(config, actions) 73 | const internalHandlers: Partial = {} 74 | 75 | if (actions.has('onDrag')) 76 | internalHandlers.drag = includeStartEndHandlers(handlers, 'onDrag') 77 | if (actions.has('onWheel')) 78 | internalHandlers.wheel = includeStartEndHandlers(handlers, 'onWheel') 79 | if (actions.has('onScroll')) 80 | internalHandlers.scroll = includeStartEndHandlers(handlers, 'onScroll') 81 | if (actions.has('onMove')) 82 | internalHandlers.move = includeStartEndHandlers(handlers, 'onMove') 83 | if (actions.has('onPinch')) 84 | internalHandlers.pinch = includeStartEndHandlers(handlers, 'onPinch') 85 | if (actions.has('onHover')) internalHandlers.hover = handlers.onHover 86 | 87 | return useRecognizers(internalHandlers, mergedConfig, nativeHandlers) 88 | } 89 | 90 | /** 91 | * @private 92 | * 93 | * This utility function will integrate start and end handlers into the regular 94 | * handler function by using first and last conditions. 95 | * 96 | * @param {UserHandlersPartial} handlers - the handlers function object 97 | * @param {HandlerKey} handlerKey - the key for which to integrate start and end handlers 98 | * @returns 99 | */ 100 | type HandlerKey = 101 | | 'onDrag' 102 | | 'onPinch' 103 | | 'onWheel' 104 | | 'onMove' 105 | | 'onScroll' 106 | | 'onHover' 107 | function includeStartEndHandlers( 108 | handlers: Partial, 109 | handlerKey: HandlerKey, 110 | ) { 111 | const startKey = (handlerKey + 'Start') as keyof UserHandlers 112 | const endKey = (handlerKey + 'End') as keyof UserHandlers 113 | 114 | const fn = (state: any) => { 115 | let memo: any = undefined 116 | if (state.first && startKey in handlers) handlers[startKey]!(state) 117 | if (handlerKey in handlers) memo = handlers[handlerKey]!(state) 118 | if (state.last && endKey in handlers) handlers[endKey]!(state) 119 | return memo 120 | } 121 | return fn 122 | } 123 | -------------------------------------------------------------------------------- /src/composables/useHover.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue-demi' 2 | import { MoveRecognizer } from '../recognizers/MoveRecognizer' 3 | import { RecognizersMap } from '../recognizers/Recognizer' 4 | import { EventTypes, Handler, UseHoverConfig } from '../types' 5 | import memoize from '../utils/memoize-one' 6 | import isEqual from '../utils/react-fast-compare' 7 | import { _buildHoverConfig } from './buildConfig' 8 | import useRecognizers from './useRecognizers' 9 | 10 | /** 11 | * Hover hook. 12 | * 13 | * @param handler - the function fired every time the hover gesture updates 14 | * @param [config={}] - the config object including generic options and hover options 15 | */ 16 | export function useHover( 17 | handler: Handler<'hover', K>, 18 | config: UseHoverConfig | {} = {}, 19 | ) { 20 | RecognizersMap.set('hover', MoveRecognizer) 21 | 22 | const buildHoverConfig = ref() 23 | 24 | if (!buildHoverConfig.value) { 25 | buildHoverConfig.value = memoize(_buildHoverConfig, isEqual) 26 | } 27 | 28 | return useRecognizers({ hover: handler }, buildHoverConfig.value(config)) 29 | } 30 | -------------------------------------------------------------------------------- /src/composables/useMove.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue-demi' 2 | import { MoveRecognizer } from '../recognizers/MoveRecognizer' 3 | import { RecognizersMap } from '../recognizers/Recognizer' 4 | import { EventTypes, Handler, UseMoveConfig } from '../types' 5 | import memoize from '../utils/memoize-one' 6 | import isEqual from '../utils/react-fast-compare' 7 | import { _buildMoveConfig } from './buildConfig' 8 | import useRecognizers from './useRecognizers' 9 | 10 | /** 11 | * Move hook. 12 | * 13 | * @param handler - the function fired every time the move gesture updates 14 | * @param [config={}] - the config object including generic options and move options 15 | */ 16 | export function useMove( 17 | handler: Handler<'move', K>, 18 | config: UseMoveConfig | {} = {}, 19 | ) { 20 | RecognizersMap.set('move', MoveRecognizer) 21 | 22 | const buildMoveConfig = ref() 23 | 24 | if (!buildMoveConfig.value) { 25 | buildMoveConfig.value = memoize(_buildMoveConfig, isEqual) 26 | } 27 | 28 | return useRecognizers({ move: handler }, buildMoveConfig.value(config)) 29 | } 30 | -------------------------------------------------------------------------------- /src/composables/usePinch.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue-demi' 2 | import { PinchRecognizer } from '../recognizers/PinchRecognizer' 3 | import { RecognizersMap } from '../recognizers/Recognizer' 4 | import { EventTypes, Handler, UsePinchConfig } from '../types' 5 | import memoize from '../utils/memoize-one' 6 | import isEqual from '../utils/react-fast-compare' 7 | import { _buildPinchConfig } from './buildConfig' 8 | import useRecognizers from './useRecognizers' 9 | 10 | /** 11 | * Pinch hook. 12 | * 13 | * @param handler - the function fired every time the pinch gesture updates 14 | * @param [config={}] - the config object including generic options and pinch options 15 | */ 16 | export function usePinch( 17 | handler: Handler<'pinch', K>, 18 | config: UsePinchConfig | {} = {}, 19 | ) { 20 | RecognizersMap.set('pinch', PinchRecognizer) 21 | 22 | const buildPinchConfig = ref() 23 | 24 | if (!buildPinchConfig.value) { 25 | buildPinchConfig.value = memoize(_buildPinchConfig, isEqual) 26 | } 27 | 28 | return useRecognizers({ pinch: handler }, buildPinchConfig.value(config)) 29 | } 30 | -------------------------------------------------------------------------------- /src/composables/useRecognizers.ts: -------------------------------------------------------------------------------- 1 | import { getCurrentInstance, onMounted, onUnmounted } from 'vue-demi' 2 | import Controller from '../Controller' 3 | import { RecognizersMap } from '../recognizers/Recognizer' 4 | import { 5 | InternalConfig, 6 | InternalHandlers, 7 | NativeHandlers, 8 | RecognizerClass, 9 | } from '../types' 10 | 11 | /** 12 | * Utility hook called by all gesture hooks and that will be responsible for the internals. 13 | * 14 | * @param handlers 15 | * @param classes 16 | * @param config 17 | * @param nativeHandlers - native handlers such as onClick, onMouseDown, etc. 18 | */ 19 | export default function useRecognizers( 20 | handlers: Partial, 21 | config: InternalConfig, 22 | nativeHandlers: Partial = {}, 23 | ) { 24 | const classes = resolveClasses(handlers) 25 | const controller = new Controller(classes) 26 | controller!.config = config 27 | controller!.handlers = handlers 28 | controller!.nativeRefs = nativeHandlers 29 | 30 | // Unbind when host component unmounts 31 | if (getCurrentInstance() && !config.manual) { 32 | onMounted(controller.bind) 33 | onUnmounted(controller.clean) 34 | } 35 | 36 | return controller 37 | } 38 | 39 | function resolveClasses(internalHandlers: Partial) { 40 | const classes = new Set() 41 | 42 | if (internalHandlers.drag) classes.add(RecognizersMap.get('drag')!) 43 | if (internalHandlers.wheel) classes.add(RecognizersMap.get('wheel')!) 44 | if (internalHandlers.scroll) classes.add(RecognizersMap.get('scroll')!) 45 | if (internalHandlers.move) classes.add(RecognizersMap.get('move')!) 46 | if (internalHandlers.pinch) classes.add(RecognizersMap.get('pinch')!) 47 | if (internalHandlers.hover) classes.add(RecognizersMap.get('hover')!) 48 | 49 | return classes 50 | } 51 | -------------------------------------------------------------------------------- /src/composables/useScroll.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue-demi' 2 | import { RecognizersMap } from '../recognizers/Recognizer' 3 | import { ScrollRecognizer } from '../recognizers/ScrollRecognizer' 4 | import { EventTypes, Handler, UseScrollConfig } from '../types' 5 | import memoize from '../utils/memoize-one' 6 | import isEqual from '../utils/react-fast-compare' 7 | import { _buildScrollConfig } from './buildConfig' 8 | import useRecognizers from './useRecognizers' 9 | 10 | /** 11 | * Scroll hook. 12 | * 13 | * @param handler - the function fired every time the scroll gesture updates 14 | * @param [config={}] - the config object including generic options and scroll options 15 | */ 16 | export function useScroll( 17 | handler: Handler<'scroll', K>, 18 | config: UseScrollConfig | {} = {}, 19 | ) { 20 | RecognizersMap.set('scroll', ScrollRecognizer) 21 | 22 | const buildScrollConfig = ref() 23 | 24 | if (!buildScrollConfig.value) { 25 | buildScrollConfig.value = memoize(_buildScrollConfig, isEqual) 26 | } 27 | 28 | return useRecognizers({ scroll: handler }, buildScrollConfig.value(config)) 29 | } 30 | -------------------------------------------------------------------------------- /src/composables/useWheel.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue-demi' 2 | import { RecognizersMap } from '../recognizers/Recognizer' 3 | import { WheelRecognizer } from '../recognizers/WheelRecognizer' 4 | import { EventTypes, Handler, UseWheelConfig } from '../types' 5 | import memoize from '../utils/memoize-one' 6 | import isEqual from '../utils/react-fast-compare' 7 | import { _buildWheelConfig } from './buildConfig' 8 | import useRecognizers from './useRecognizers' 9 | 10 | /** 11 | * Wheel hook. 12 | * 13 | * @param handler - the function fired every time the wheel gesture updates 14 | * @param the config object including generic options and wheel options 15 | */ 16 | export function useWheel( 17 | handler: Handler<'wheel', K>, 18 | config: UseWheelConfig | {} = {}, 19 | ) { 20 | RecognizersMap.set('wheel', WheelRecognizer) 21 | 22 | const buildWheelConfig = ref() 23 | 24 | if (!buildWheelConfig.value) { 25 | buildWheelConfig.value = memoize(_buildWheelConfig, isEqual) 26 | } 27 | 28 | return useRecognizers({ wheel: handler }, buildWheelConfig.value(config)) 29 | } 30 | -------------------------------------------------------------------------------- /src/directives/directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, DirectiveBinding, VNode } from 'vue-demi' 2 | 3 | export type DirectiveHook = ( 4 | el: HTMLElement | SVGElement, 5 | binding: DirectiveBinding, 6 | node: VNode< 7 | any, 8 | HTMLElement | SVGElement, 9 | { 10 | [key: string]: any 11 | } 12 | >, 13 | ) => void 14 | 15 | export const directive = ( 16 | register: DirectiveHook, 17 | unregister: DirectiveHook, 18 | ): Directive => { 19 | return { 20 | // Vue 3 Directive Hooks 21 | created: register, 22 | unmounted: unregister, 23 | // Vue 2 Directive Hooks 24 | // @ts-expect-error 25 | bind: register, 26 | unbind: unregister, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/directives/index.ts: -------------------------------------------------------------------------------- 1 | import { useDrag } from '../composables/useDrag' 2 | import { useHover } from '../composables/useHover' 3 | import { useMove } from '../composables/useMove' 4 | import { usePinch } from '../composables/usePinch' 5 | import { useScroll } from '../composables/useScroll' 6 | import { useWheel } from '../composables/useWheel' 7 | import { directive, DirectiveHook } from './directive' 8 | 9 | const errorMessage = (type: string) => 10 | `Your v-${type} directive must have a handler specified as a value` 11 | 12 | // v-drag 13 | export const drag = () => { 14 | const register: DirectiveHook = (el, binding, node) => { 15 | if (!binding.value) { 16 | throw new Error(errorMessage('drag')) 17 | } 18 | 19 | if (!el.gestures) { 20 | el.gestures = {} 21 | } 22 | 23 | const controller = useDrag(binding.value, { 24 | domTarget: el, 25 | manual: true, 26 | }) 27 | 28 | controller.bind() 29 | 30 | el.gestures.drag = controller 31 | } 32 | 33 | const unregister: DirectiveHook = (el, binding, node) => { 34 | if (!el.gestures || !el.gestures.drag) return 35 | 36 | el.gestures.drag.clean() 37 | } 38 | 39 | return directive(register, unregister) 40 | } 41 | 42 | // v-move 43 | export const move = () => { 44 | const register: DirectiveHook = (el, binding, node) => { 45 | if (!binding.value) { 46 | throw new Error(errorMessage('move')) 47 | } 48 | 49 | if (!el.gestures) { 50 | el.gestures = {} 51 | } 52 | 53 | const controller = useMove(binding.value, { 54 | domTarget: el, 55 | manual: true, 56 | }) 57 | 58 | controller.bind() 59 | 60 | el.gestures.move = controller 61 | } 62 | 63 | const unregister: DirectiveHook = (el, binding, node) => { 64 | if (!el.gestures || !el.gestures.move) return 65 | 66 | el.gestures.move.clean() 67 | } 68 | 69 | return directive(register, unregister) 70 | } 71 | 72 | // v-hover 73 | export const hover = () => { 74 | const register: DirectiveHook = (el, binding, node) => { 75 | if (!binding.value) { 76 | throw new Error(errorMessage('hover')) 77 | } 78 | 79 | if (!el.gestures) { 80 | el.gestures = {} 81 | } 82 | 83 | const controller = useHover(binding.value, { 84 | domTarget: el, 85 | manual: true, 86 | }) 87 | 88 | controller.bind() 89 | 90 | el.gestures.hover = controller 91 | } 92 | 93 | const unregister: DirectiveHook = (el, binding, node) => { 94 | if (!el.gestures || !el.gestures.hover) return 95 | 96 | el.gestures.hover.clean() 97 | } 98 | 99 | return directive(register, unregister) 100 | } 101 | 102 | // v-pinch 103 | export const pinch = () => { 104 | const register: DirectiveHook = (el, binding, node) => { 105 | if (!binding.value) { 106 | throw new Error(errorMessage('pinch')) 107 | } 108 | 109 | if (!el.gestures) { 110 | el.gestures = {} 111 | } 112 | 113 | const controller = usePinch(binding.value, { 114 | domTarget: el, 115 | manual: true, 116 | }) 117 | 118 | controller.bind() 119 | 120 | el.gestures.pinch = controller 121 | } 122 | 123 | const unregister: DirectiveHook = (el, binding, node) => { 124 | if (!el.gestures || !el.gestures.pinch) return 125 | 126 | el.gestures.pinch.clean() 127 | } 128 | 129 | return directive(register, unregister) 130 | } 131 | 132 | // v-wheel 133 | export const wheel = () => { 134 | const register: DirectiveHook = (el, binding, node) => { 135 | if (!binding.value) { 136 | throw new Error(errorMessage('wheel')) 137 | } 138 | 139 | if (!el.gestures) { 140 | el.gestures = {} 141 | } 142 | 143 | const controller = useWheel(binding.value, { 144 | domTarget: el, 145 | manual: true, 146 | }) 147 | 148 | controller.bind() 149 | 150 | el.gestures.wheel = controller 151 | } 152 | 153 | const unregister: DirectiveHook = (el, binding, node) => { 154 | if (!el.gestures || !el.gestures.wheel) return 155 | 156 | el.gestures.wheel.clean() 157 | } 158 | 159 | return directive(register, unregister) 160 | } 161 | 162 | // v-scroll 163 | export const scroll = () => { 164 | const register: DirectiveHook = (el, binding, node) => { 165 | if (!binding.value) { 166 | throw new Error(errorMessage('scroll')) 167 | } 168 | 169 | if (!el.gestures) { 170 | el.gestures = {} 171 | } 172 | 173 | const controller = useScroll(binding.value, { 174 | domTarget: el, 175 | manual: true, 176 | }) 177 | 178 | controller.bind() 179 | 180 | el.gestures.drag = controller 181 | } 182 | 183 | const unregister: DirectiveHook = (el, binding, node) => { 184 | if (!el.gestures || !el.gestures.drag) return 185 | 186 | el.gestures.drag.clean() 187 | } 188 | 189 | return directive(register, unregister) 190 | } 191 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { useDrag } from './composables/useDrag' 2 | export { useGesture } from './composables/useGesture' 3 | export { useHover } from './composables/useHover' 4 | export { useMove } from './composables/useMove' 5 | export { usePinch } from './composables/usePinch' 6 | export { useScroll } from './composables/useScroll' 7 | export { useWheel } from './composables/useWheel' 8 | export { 9 | drag as dragDirective, 10 | hover as hoverDirective, 11 | move as moveDirective, 12 | pinch as pinchDirective, 13 | scroll as scrollDirective, 14 | wheel as wheelDirective, 15 | } from './directives' 16 | export { GesturePlugin } from './plugin' 17 | export * from './types/index' 18 | export { addV, subV } from './utils/math' 19 | export { rubberbandIfOutOfBounds } from './utils/rubberband' 20 | -------------------------------------------------------------------------------- /src/plugin/index.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from 'vue-demi' 2 | import { drag, hover, move, pinch, scroll, wheel } from '../directives' 3 | 4 | export const GesturePlugin: Plugin = { 5 | install(app, options) { 6 | const directives = { drag, hover, move, pinch, scroll, wheel } 7 | 8 | Object.entries(directives).forEach(([key, directive]) => 9 | app.directive(key, directive()), 10 | ) 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /src/recognizers/CoordinatesRecognizer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CoordinatesKey, 3 | GestureState, 4 | PartialGestureState, 5 | Vector2, 6 | } from '../types' 7 | import { calculateAllKinematics, subV } from '../utils/math' 8 | import Recognizer from './Recognizer' 9 | 10 | /** 11 | * @private 12 | * Abstract class for coordinates-based gesture recongizers 13 | */ 14 | export default abstract class CoordinatesRecognizer< 15 | T extends CoordinatesKey 16 | > extends Recognizer { 17 | /** 18 | * Returns the real movement (without taking intentionality into account) 19 | */ 20 | protected getInternalMovement( 21 | values: Vector2, 22 | state: GestureState, 23 | ): Vector2 { 24 | return subV(values, state.initial) 25 | } 26 | 27 | /** 28 | * In coordinates-based gesture, this function will detect the first intentional axis, 29 | * lock the gesture axis if lockDirection is specified in the config, block the gesture 30 | * if the first intentional axis doesn't match the specified axis in config. 31 | */ 32 | protected checkIntentionality( 33 | _intentional: [false | number, false | number], 34 | _movement: Vector2, 35 | ): PartialGestureState { 36 | if (_intentional[0] === false && _intentional[1] === false) { 37 | return { _intentional, axis: this.state.axis } as PartialGestureState 38 | } 39 | const [absX, absY] = _movement.map(Math.abs) 40 | const axis = 41 | this.state.axis || (absX > absY ? 'x' : absX < absY ? 'y' : undefined) 42 | if (!this.config.axis && !this.config.lockDirection) 43 | return { _intentional, _blocked: false, axis } as any 44 | if (!axis) 45 | return { _intentional: [false, false], _blocked: false, axis } as any 46 | if (!!this.config.axis && axis !== this.config.axis) 47 | return { _intentional, _blocked: true, axis } as any 48 | _intentional![axis === 'x' ? 1 : 0] = false 49 | return { _intentional, _blocked: false, axis } as any 50 | } 51 | 52 | getKinematics(values: Vector2, event: UIEvent): PartialGestureState { 53 | const state = this.getMovement(values) 54 | if (!state._blocked) { 55 | const dt = event.timeStamp - this.state.timeStamp! 56 | Object.assign( 57 | state, 58 | calculateAllKinematics(state.movement!, state.delta!, dt), 59 | ) 60 | } 61 | return state 62 | } 63 | 64 | protected mapStateValues( 65 | state: GestureState, 66 | ): Omit, 'event'> { 67 | return { xy: state.values, vxvy: state.velocities } as Omit< 68 | PartialGestureState, 69 | 'event' 70 | > 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/recognizers/DistanceAngleRecognizer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DistanceAngleKey, 3 | GestureState, 4 | PartialGestureState, 5 | Vector2, 6 | } from '../types' 7 | import { calculateAllKinematics, sign, subV } from '../utils/math' 8 | import Recognizer from './Recognizer' 9 | 10 | /** 11 | * @private 12 | * Abstract class for distance/angle-based gesture recongizers 13 | */ 14 | export default abstract class DistanceAngleRecognizer< 15 | T extends DistanceAngleKey 16 | > extends Recognizer { 17 | protected getInternalMovement( 18 | values: [number, number?], 19 | state: GestureState, 20 | ): Vector2 { 21 | const prev_a = state.values[1] 22 | // not be defined if ctrl+wheel is used for zoom only 23 | let [d, a = prev_a] = values 24 | 25 | let delta_a = a - prev_a 26 | let next_turns = state.turns 27 | if (Math.abs(delta_a) > 270) next_turns += sign(delta_a) 28 | return subV([d, a - 360 * next_turns], state.initial) 29 | } 30 | 31 | getKinematics(values: Vector2, event: UIEvent): PartialGestureState { 32 | const state = this.getMovement(values) 33 | const turns = 34 | (values[1] - state._movement![1] - this.state.initial[1]) / 360 35 | const dt = event.timeStamp - this.state.timeStamp! 36 | const { distance, velocity, ...kinematics } = calculateAllKinematics( 37 | state.movement!, 38 | state.delta!, 39 | dt, 40 | ) 41 | return { turns, ...state, ...kinematics } 42 | } 43 | 44 | protected mapStateValues( 45 | state: GestureState, 46 | ): Omit, 'event'> { 47 | return { da: state.values, vdva: state.velocities } as Omit< 48 | PartialGestureState, 49 | 'event' 50 | > 51 | } 52 | } 53 | 54 | /** 55 | * @param dangle is a small change of variable on "lifting" of the circle. 56 | * It's expected to be small and cannot be greater than 270 or under -270 57 | */ 58 | export function fixContinuity(dangle: number) { 59 | dangle -= Math.round(dangle / 360) * 360 60 | if (dangle > 270) return dangle - 360 61 | if (dangle < -270) return dangle + 360 62 | return dangle 63 | } 64 | -------------------------------------------------------------------------------- /src/recognizers/DragRecognizer.ts: -------------------------------------------------------------------------------- 1 | import { nextTick } from 'vue-demi' 2 | import { 3 | addBindings, 4 | addEventIds, 5 | clearWindowListeners, 6 | removeEventIds, 7 | updateWindowListeners, 8 | } from '../Controller' 9 | import { GestureTarget } from '../types' 10 | import { getGenericEventData, getPointerEventValues } from '../utils/event' 11 | import { addV, calculateDistance, sign } from '../utils/math' 12 | import CoordinatesRecognizer from './CoordinatesRecognizer' 13 | import { getGenericPayload, getStartGestureState } from './Recognizer' 14 | 15 | export const TAP_DISTANCE_THRESHOLD = 3 16 | export const SWIPE_MAX_ELAPSED_TIME = 220 17 | 18 | function persistEvent(event: PointerEvent) { 19 | // @ts-ignore 20 | 'persist' in event && typeof event.persist === 'function' && event.persist() 21 | } 22 | 23 | export class DragRecognizer extends CoordinatesRecognizer<'drag'> { 24 | readonly ingKey = 'dragging' 25 | readonly stateKey = 'drag' 26 | 27 | // TODO: add back when setPointerCapture is widely supported 28 | // https://caniuse.com/#search=setPointerCapture 29 | private setPointerCapture = (event: PointerEvent) => { 30 | if (this.config.useTouch || document.pointerLockElement) return 31 | 32 | const { target, pointerId } = event 33 | if (target && 'setPointerCapture' in target) { 34 | // @ts-expect-error 35 | target.setPointerCapture(pointerId) 36 | } 37 | 38 | this.updateGestureState({ 39 | _dragTarget: target as GestureTarget, 40 | _dragPointerId: pointerId, 41 | }) 42 | } 43 | 44 | private releasePointerCapture = () => { 45 | if (this.config.useTouch || document.pointerLockElement) return 46 | 47 | const { _dragTarget, _dragPointerId } = this.state 48 | if ( 49 | _dragPointerId && 50 | _dragTarget && 51 | 'releasePointerCapture' in _dragTarget 52 | ) { 53 | if ( 54 | !('hasPointerCapture' in _dragTarget) || 55 | _dragTarget.hasPointerCapture(_dragPointerId) 56 | ) 57 | try { 58 | _dragTarget.releasePointerCapture(_dragPointerId) 59 | } catch (e) {} 60 | } 61 | } 62 | 63 | private preventScroll = (event: TouchEvent) => { 64 | if (this.state._dragPreventScroll && event.cancelable) { 65 | event.preventDefault() 66 | } 67 | } 68 | 69 | private getEventId = (event: any): number => { 70 | if (this.config.useTouch) return event.changedTouches[0].identifier 71 | return event.pointerId 72 | } 73 | 74 | private isValidEvent = (event: any) => { 75 | // if we were using pointer events only event.isPrimary === 1 would suffice 76 | return this.state._pointerId === this.getEventId(event) 77 | } 78 | 79 | private shouldPreventWindowScrollY = 80 | this.config.preventWindowScrollY && this.controller.supportsTouchEvents 81 | 82 | private setUpWindowScrollDetection = (event: PointerEvent) => { 83 | persistEvent(event) 84 | // we add window listeners that will prevent the scroll when the user has started dragging 85 | updateWindowListeners( 86 | this.controller, 87 | this.stateKey, 88 | [ 89 | ['touchmove', this.preventScroll], 90 | ['touchend', this.clean.bind(this)], 91 | ['touchcancel', this.clean.bind(this)], 92 | ], 93 | { passive: false }, 94 | ) 95 | this.setTimeout(this.startDrag.bind(this), 250, event) 96 | } 97 | 98 | private setUpDelayedDragTrigger = (event: PointerEvent) => { 99 | this.state._dragDelayed = true 100 | persistEvent(event) 101 | this.setTimeout(this.startDrag.bind(this), this.config.delay, event) 102 | } 103 | 104 | private setStartState = (event: PointerEvent) => { 105 | const values = getPointerEventValues(event, this.transform) 106 | this.updateSharedState(getGenericEventData(event)) 107 | 108 | this.updateGestureState({ 109 | ...getStartGestureState(this, values, event), 110 | ...getGenericPayload(this, event, true), 111 | _pointerId: this.getEventId(event), // setting pointerId locks the gesture to this specific event 112 | }) 113 | 114 | this.updateGestureState(this.getMovement(values)) 115 | } 116 | 117 | onDragStart = (event: PointerEvent): void => { 118 | addEventIds(this.controller, event) 119 | if (!this.enabled || this.state._active) return 120 | 121 | this.setStartState(event) 122 | this.setPointerCapture(event as PointerEvent) 123 | 124 | if (this.shouldPreventWindowScrollY) this.setUpWindowScrollDetection(event) 125 | else if (this.config.delay > 0) this.setUpDelayedDragTrigger(event) 126 | else this.startDrag(event, true) // we pass the values to the startDrag event 127 | } 128 | 129 | startDrag(event: PointerEvent, onDragIsStart: boolean = false) { 130 | // startDrag can happen after a timeout, so we need to check if the gesture is still active 131 | // as the user might have lift up the pointer in between. 132 | 133 | if ( 134 | // if the gesture isn't active (probably means) 135 | !this.state._active || 136 | // if the drag has already started we should ignore subsequent attempts 137 | this.state._dragStarted 138 | ) 139 | return 140 | 141 | if (!onDragIsStart) this.setStartState(event) 142 | this.updateGestureState({ 143 | _dragStarted: true, 144 | _dragPreventScroll: true, 145 | cancel: this.onCancel, 146 | }) 147 | this.clearTimeout() 148 | 149 | this.fireGestureHandler() 150 | } 151 | 152 | onDragChange = (event: PointerEvent): void => { 153 | if ( 154 | // if the gesture was canceled or 155 | this.state.canceled || 156 | // if onDragStart wasn't fired or 157 | !this.state._active || 158 | // if the event pointerId doesn't match the one that initiated the drag 159 | !this.isValidEvent(event) || 160 | // if the event has the same timestamp as the previous event 161 | // note that checking type equality is ONLY for tests ¯\_(ツ)_/¯ 162 | (this.state._lastEventType === event.type && 163 | event.timeStamp === this.state.timeStamp) 164 | ) 165 | return 166 | 167 | let values 168 | 169 | if (document.pointerLockElement) { 170 | const { movementX, movementY } = event 171 | values = addV(this.transform([movementX, movementY]), this.state.values) 172 | } else values = getPointerEventValues(event, this.transform) 173 | 174 | const kinematics = this.getKinematics(values, event) 175 | 176 | // if startDrag hasn't fired 177 | if (!this.state._dragStarted) { 178 | // If the gesture isn't active then respond to the event only if 179 | // it's been delayed via the `delay` option, in which case start 180 | // the gesture immediately. 181 | if (this.state._dragDelayed) { 182 | this.startDrag(event) 183 | return 184 | } 185 | // if the user wants to prevent vertical window scroll when user starts dragging 186 | if (this.shouldPreventWindowScrollY) { 187 | if (!this.state._dragPreventScroll && kinematics.axis) { 188 | // if the user is dragging horizontally then we should allow the drag 189 | if (kinematics.axis === 'x') { 190 | this.startDrag(event) 191 | } else { 192 | this.state._active = false 193 | return 194 | } 195 | } else return 196 | } else return 197 | } 198 | 199 | const genericEventData = getGenericEventData(event) 200 | 201 | this.updateSharedState(genericEventData) 202 | const genericPayload = getGenericPayload(this, event) 203 | 204 | // This verifies if the drag can be assimilated to a tap by checking 205 | // if the real distance of the drag (ie not accounting for the threshold) is 206 | // greater than the TAP_DISTANCE_THRESHOLD. 207 | const realDistance = calculateDistance(kinematics._movement!) 208 | let { _dragIsTap } = this.state 209 | if (_dragIsTap && realDistance >= TAP_DISTANCE_THRESHOLD) _dragIsTap = false 210 | 211 | this.updateGestureState({ ...genericPayload, ...kinematics, _dragIsTap }) 212 | 213 | this.fireGestureHandler() 214 | } 215 | 216 | onDragEnd = (event: PointerEvent): void => { 217 | removeEventIds(this.controller, event) 218 | 219 | // if the event pointerId doesn't match the one that initiated the drag 220 | // we don't want to end the drag 221 | if (!this.isValidEvent(event)) return 222 | this.clean() 223 | 224 | // if the gesture is no longer active (ie canceled) 225 | // don't do anything 226 | if (!this.state._active) return 227 | this.state._active = false 228 | 229 | const tap = this.state._dragIsTap 230 | const [vx, vy] = this.state.velocities 231 | const [mx, my] = this.state.movement 232 | const [ix, iy] = this.state._intentional 233 | const [svx, svy] = this.config.swipeVelocity 234 | const [sx, sy] = this.config.swipeDistance 235 | const sd = this.config.swipeDuration 236 | 237 | const endState = { 238 | ...getGenericPayload(this, event), 239 | ...this.getMovement(this.state.values), 240 | } 241 | 242 | const swipe: [number, number] = [0, 0] 243 | 244 | if (endState.elapsedTime < sd) { 245 | if (ix !== false && Math.abs(vx) > svx && Math.abs(mx) > sx) 246 | swipe[0] = sign(vx) 247 | if (iy !== false && Math.abs(vy) > svy && Math.abs(my) > sy) 248 | swipe[1] = sign(vy) 249 | } 250 | 251 | this.updateSharedState({ buttons: 0 }) 252 | this.updateGestureState({ ...endState, tap, swipe }) 253 | this.fireGestureHandler(this.config.filterTaps && tap === true) 254 | } 255 | 256 | clean = (): void => { 257 | super.clean() 258 | this.state._dragStarted = false 259 | this.releasePointerCapture() 260 | clearWindowListeners(this.controller, this.stateKey) 261 | } 262 | 263 | onCancel = (): void => { 264 | if (this.state.canceled) return 265 | this.updateGestureState({ canceled: true, _active: false }) 266 | this.updateSharedState({ buttons: 0 }) 267 | nextTick(this.fireGestureHandler) 268 | } 269 | 270 | onClick = (event: UIEvent): void => { 271 | if (!this.state._dragIsTap) event.stopPropagation() 272 | } 273 | 274 | addBindings(bindings: any): void { 275 | if (this.config.useTouch) { 276 | addBindings(bindings, 'onTouchStart', this.onDragStart) 277 | addBindings(bindings, 'onTouchMove', this.onDragChange) 278 | addBindings(bindings, 'onTouchEnd', this.onDragEnd) 279 | addBindings(bindings, 'onTouchCancel', this.onDragEnd) 280 | } else { 281 | addBindings(bindings, 'onPointerDown', this.onDragStart) 282 | addBindings(bindings, 'onPointerMove', this.onDragChange) 283 | addBindings(bindings, 'onPointerUp', this.onDragEnd) 284 | addBindings(bindings, 'onPointerCancel', this.onDragEnd) 285 | } 286 | 287 | if (this.config.filterTaps) { 288 | const handler = this.controller.config.eventOptions.capture 289 | ? 'onClick' 290 | : 'onClickCapture' 291 | addBindings(bindings, handler, this.onClick) 292 | } 293 | } 294 | } 295 | -------------------------------------------------------------------------------- /src/recognizers/MoveRecognizer.ts: -------------------------------------------------------------------------------- 1 | import { addBindings } from '../Controller' 2 | import { getGenericEventData, getPointerEventValues } from '../utils/event' 3 | import CoordinatesRecognizer from './CoordinatesRecognizer' 4 | import { getGenericPayload, getStartGestureState } from './Recognizer' 5 | 6 | export class MoveRecognizer extends CoordinatesRecognizer<'move'> { 7 | readonly ingKey = 'moving' 8 | readonly stateKey = 'move' 9 | 10 | debounced = true 11 | 12 | onMove = (event: PointerEvent): void => { 13 | if (!this.enabled) return 14 | this.setTimeout(this.onMoveEnd) 15 | 16 | if (!this.state._active) this.onMoveStart(event) 17 | else this.onMoveChange(event) 18 | } 19 | 20 | onMoveStart = (event: PointerEvent): void => { 21 | this.updateSharedState(getGenericEventData(event)) 22 | const values = getPointerEventValues(event, this.transform) 23 | 24 | this.updateGestureState({ 25 | ...getStartGestureState(this, values, event), 26 | ...getGenericPayload(this, event, true), 27 | }) 28 | 29 | this.updateGestureState(this.getMovement(values)) 30 | this.fireGestureHandler() 31 | } 32 | 33 | onMoveChange = (event: PointerEvent): void => { 34 | this.updateSharedState(getGenericEventData(event)) 35 | const values = getPointerEventValues(event, this.transform) 36 | 37 | this.updateGestureState({ 38 | ...getGenericPayload(this, event), 39 | ...this.getKinematics(values, event), 40 | }) 41 | 42 | this.fireGestureHandler() 43 | } 44 | 45 | onMoveEnd = (): void => { 46 | this.clean() 47 | if (!this.state._active) return 48 | const values = this.state.values 49 | this.updateGestureState(this.getMovement(values)) 50 | this.updateGestureState({ velocities: [0, 0], velocity: 0, _active: false }) 51 | 52 | this.fireGestureHandler() 53 | } 54 | 55 | hoverTransform = () => { 56 | return ( 57 | this.controller.config.hover!.transform || 58 | this.controller.config.transform 59 | ) 60 | } 61 | 62 | onPointerEnter = (event: PointerEvent): void => { 63 | this.controller.state.shared.hovering = true 64 | if (!this.controller.config.enabled) return 65 | 66 | if (this.controller.config.hover!.enabled) { 67 | const values = getPointerEventValues(event, this.hoverTransform()) 68 | 69 | const state = { 70 | ...this.controller.state.shared, 71 | ...this.state, 72 | ...getGenericPayload(this, event, true), 73 | args: this.args, 74 | values, 75 | active: true, 76 | hovering: true, 77 | } 78 | 79 | this.controller.handlers.hover!({ 80 | ...state, 81 | ...this.mapStateValues(state), 82 | }) 83 | } 84 | 85 | if ('move' in this.controller.handlers) this.onMoveStart(event) 86 | } 87 | 88 | onPointerLeave = (event: PointerEvent): void => { 89 | this.controller.state.shared.hovering = false 90 | if ('move' in this.controller.handlers) this.onMoveEnd() 91 | if (!this.controller.config.hover!.enabled) return 92 | 93 | const values = getPointerEventValues(event, this.hoverTransform()) 94 | 95 | const state = { 96 | ...this.controller.state.shared, 97 | ...this.state, 98 | ...getGenericPayload(this, event), 99 | args: this.args, 100 | values, 101 | active: false, 102 | } 103 | 104 | this.controller.handlers.hover!({ ...state, ...this.mapStateValues(state) }) 105 | } 106 | 107 | addBindings(bindings: any): void { 108 | if ('move' in this.controller.handlers) { 109 | addBindings(bindings, 'onPointerMove', this.onMove) 110 | } 111 | if ('hover' in this.controller.handlers) { 112 | addBindings(bindings, 'onPointerEnter', this.onPointerEnter) 113 | addBindings(bindings, 'onPointerLeave', this.onPointerLeave) 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/recognizers/PinchRecognizer.ts: -------------------------------------------------------------------------------- 1 | import { addBindings, addEventIds, removeEventIds } from '../Controller' 2 | import { Vector2, WebKitGestureEvent } from '../types' 3 | import { 4 | getGenericEventData, 5 | getTouchIds, 6 | getTwoTouchesEventValues, 7 | getWebkitGestureEventValues, 8 | getWheelEventValues, 9 | } from '../utils/event' 10 | import DistanceAngleRecognizer from './DistanceAngleRecognizer' 11 | import { getGenericPayload, getStartGestureState } from './Recognizer' 12 | 13 | const ZOOM_CONSTANT = 7 14 | const WEBKIT_DISTANCE_SCALE_FACTOR = 260 15 | 16 | export class PinchRecognizer extends DistanceAngleRecognizer<'pinch'> { 17 | readonly ingKey = 'pinching' 18 | readonly stateKey = 'pinch' 19 | 20 | onPinchStart = (event: TouchEvent) => { 21 | addEventIds(this.controller, event) 22 | const touchIds = this.controller.touchIds 23 | 24 | if (!this.enabled) return 25 | 26 | if (this.state._active) { 27 | // check that the pointerIds that initiated the gesture 28 | // are still enabled. This is useful for when the page 29 | // loses track of the pointers (minifying gesture on iPad). 30 | if (this.state._pointerIds.every((id) => touchIds.has(id))) return 31 | // something was wrong with the pointers but we let it go. 32 | } 33 | // until we reach two fingers on the target don't react 34 | if (touchIds.size < 2) return 35 | const _pointerIds = Array.from(touchIds).slice(0, 2) as [number, number] 36 | 37 | const { values, origin } = getTwoTouchesEventValues( 38 | event, 39 | _pointerIds, 40 | this.transform, 41 | ) 42 | 43 | this.updateSharedState(getGenericEventData(event)) 44 | 45 | this.updateGestureState({ 46 | ...getStartGestureState(this, values, event), 47 | ...getGenericPayload(this, event, true), 48 | _pointerIds, 49 | cancel: this.onCancel, 50 | origin, 51 | }) 52 | 53 | this.updateGestureState(this.getMovement(values)) 54 | this.fireGestureHandler() 55 | } 56 | 57 | onPinchChange = (event: TouchEvent): void => { 58 | const { canceled, _active } = this.state 59 | if ( 60 | canceled || 61 | !_active || 62 | // if the event has the same timestamp as the previous event 63 | event.timeStamp === this.state.timeStamp 64 | ) 65 | return 66 | const genericEventData = getGenericEventData(event) 67 | 68 | this.updateSharedState(genericEventData) 69 | try { 70 | const { values, origin } = getTwoTouchesEventValues( 71 | event, 72 | this.state._pointerIds, 73 | this.transform, 74 | ) 75 | const kinematics = this.getKinematics(values, event) 76 | 77 | this.updateGestureState({ 78 | ...getGenericPayload(this, event), 79 | ...kinematics, 80 | origin, 81 | }) 82 | 83 | this.fireGestureHandler() 84 | } catch (e) { 85 | this.onPinchEnd(event) 86 | } 87 | } 88 | 89 | onPinchEnd = (event: TouchEvent): void => { 90 | removeEventIds(this.controller, event) 91 | const pointerIds = getTouchIds(event) 92 | 93 | // if none of the lifted pointerIds is in the state pointerIds don't do anything 94 | if (this.state._pointerIds.every((id) => !pointerIds.includes(id))) return 95 | 96 | this.clean() 97 | if (!this.state._active) return 98 | 99 | this.updateGestureState({ 100 | ...getGenericPayload(this, event), 101 | ...this.getMovement(this.state.values), 102 | _active: false, 103 | }) 104 | this.fireGestureHandler() 105 | } 106 | 107 | onCancel = (): void => { 108 | if (this.state.canceled) return 109 | this.updateGestureState({ _active: false, canceled: true }) 110 | this.fireGestureHandler() 111 | } 112 | /** 113 | * PINCH WITH WEBKIT GESTURES 114 | */ 115 | onGestureStart = (event: WebKitGestureEvent): void => { 116 | if (!this.enabled) return 117 | event.preventDefault() // useless 118 | 119 | const values = getWebkitGestureEventValues(event, this.transform) 120 | 121 | this.updateSharedState(getGenericEventData(event)) 122 | 123 | this.updateGestureState({ 124 | ...getStartGestureState(this, values, event), 125 | ...getGenericPayload(this, event, true), 126 | origin: [event.clientX, event.clientY] as Vector2, // only used on dekstop 127 | cancel: this.onCancel, 128 | }) 129 | 130 | this.updateGestureState(this.getMovement(values)) 131 | this.fireGestureHandler() 132 | } 133 | 134 | onGestureChange = (event: WebKitGestureEvent): void => { 135 | const { canceled, _active } = this.state 136 | if (canceled || !_active) return 137 | 138 | event.preventDefault() 139 | 140 | const genericEventData = getGenericEventData(event) 141 | 142 | this.updateSharedState(genericEventData) 143 | 144 | // this normalizes the values of the Safari's WebkitEvent by calculating 145 | // the delta and then multiplying it by a constant. 146 | const values = getWebkitGestureEventValues(event, this.transform) 147 | values[0] = 148 | (values[0] - (this.state.event as WebKitGestureEvent).scale) * 149 | WEBKIT_DISTANCE_SCALE_FACTOR + 150 | this.state.values[0] 151 | 152 | const kinematics = this.getKinematics(values, event) 153 | 154 | this.updateGestureState({ 155 | ...getGenericPayload(this, event), 156 | ...kinematics, 157 | origin: [event.clientX, event.clientY] as Vector2, // only used on dekstop 158 | }) 159 | 160 | this.fireGestureHandler() 161 | } 162 | 163 | onGestureEnd = (event: WebKitGestureEvent): void => { 164 | this.clean() 165 | if (!this.state._active) return 166 | 167 | this.updateGestureState({ 168 | ...getGenericPayload(this, event), 169 | ...this.getMovement(this.state.values), 170 | _active: false, 171 | origin: [event.clientX, event.clientY] as Vector2, // only used on dekstop 172 | }) 173 | this.fireGestureHandler() 174 | } 175 | 176 | /** 177 | * PINCH WITH WHEEL 178 | */ 179 | private wheelShouldRun = (event: WheelEvent) => { 180 | return this.enabled && event.ctrlKey 181 | } 182 | 183 | private getWheelValuesFromEvent = (event: WheelEvent) => { 184 | const [, delta_d] = getWheelEventValues(event, this.transform) 185 | const { 186 | values: [prev_d, prev_a], 187 | } = this.state 188 | // ZOOM_CONSTANT is based on Safari trackpad natural zooming 189 | const d = prev_d - delta_d * ZOOM_CONSTANT 190 | const a = prev_a !== void 0 ? prev_a : 0 191 | 192 | return { 193 | values: [d, a] as Vector2, 194 | origin: [event.clientX, event.clientY] as Vector2, 195 | delta: [0, delta_d] as Vector2, 196 | } 197 | } 198 | 199 | onWheel = (event: WheelEvent): void => { 200 | if (!this.wheelShouldRun(event)) return 201 | this.setTimeout(this.onWheelEnd) 202 | 203 | if (!this.state._active) this.onWheelStart(event) 204 | else this.onWheelChange(event) 205 | } 206 | 207 | onWheelStart = (event: WheelEvent): void => { 208 | const { values, delta, origin } = this.getWheelValuesFromEvent(event) 209 | 210 | if (event.cancelable) event.preventDefault() 211 | else if (process.env.NODE_ENV === 'development') { 212 | // eslint-disable-next-line no-console 213 | console.warn( 214 | 'To properly support zoom on trackpads, try using the `domTarget` option and `config.eventOptions.passive` set to `false`. This message will only appear in development mode.', 215 | ) 216 | } 217 | 218 | this.updateSharedState(getGenericEventData(event)) 219 | 220 | this.updateGestureState({ 221 | ...getStartGestureState(this, values, event), 222 | ...getGenericPayload(this, event, true), 223 | initial: this.state.values, 224 | offset: values, 225 | delta, 226 | origin, 227 | }) 228 | 229 | this.updateGestureState(this.getMovement(values)) 230 | this.fireGestureHandler() 231 | } 232 | 233 | onWheelChange = (event: WheelEvent): void => { 234 | if (event.cancelable) event.preventDefault() 235 | 236 | this.updateSharedState(getGenericEventData(event)) 237 | const { values, origin, delta } = this.getWheelValuesFromEvent(event) 238 | 239 | this.updateGestureState({ 240 | ...getGenericPayload(this, event), 241 | ...this.getKinematics(values, event), 242 | origin, 243 | delta, 244 | }) 245 | 246 | this.fireGestureHandler() 247 | } 248 | 249 | onWheelEnd = (): void => { 250 | this.clean() 251 | if (!this.state._active) return 252 | this.state._active = false 253 | this.updateGestureState(this.getMovement(this.state.values)) 254 | this.fireGestureHandler() 255 | } 256 | 257 | addBindings(bindings: any): void { 258 | // Only try to use gesture events when they are supported and domTarget is set 259 | // as React doesn't support gesture handlers. 260 | if ( 261 | this.controller.config.domTarget && 262 | !this.controller.supportsTouchEvents && 263 | this.controller.supportsGestureEvents 264 | ) { 265 | addBindings(bindings, 'onGestureStart', this.onGestureStart) 266 | addBindings(bindings, 'onGestureChange', this.onGestureChange) 267 | addBindings(bindings, 'onGestureEnd', this.onGestureEnd) 268 | } else { 269 | addBindings(bindings, 'onTouchStart', this.onPinchStart) 270 | addBindings(bindings, 'onTouchMove', this.onPinchChange) 271 | addBindings(bindings, 'onTouchEnd', this.onPinchEnd) 272 | addBindings(bindings, 'onTouchCancel', this.onPinchEnd) 273 | addBindings(bindings, 'onWheel', this.onWheel) 274 | } 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /src/recognizers/Recognizer.ts: -------------------------------------------------------------------------------- 1 | import Controller from '../Controller' 2 | import { 3 | EventTypes, 4 | FullGestureState, 5 | GestureKey, 6 | GestureState, 7 | IngKey, 8 | InternalConfig, 9 | PartialGestureState, 10 | RecognizerClass, 11 | SharedGestureState, 12 | StateKey, 13 | Vector2, 14 | } from '../types' 15 | import { addV, sign, subV } from '../utils/math' 16 | import { rubberbandIfOutOfBounds } from '../utils/rubberband' 17 | import { getInitialState } from '../utils/state' 18 | import { valueFn } from '../utils/utils' 19 | 20 | export const RecognizersMap = new Map() 21 | 22 | const identity = (xy: Vector2) => xy 23 | 24 | /** 25 | * @private 26 | * Recognizer abstract class. 27 | */ 28 | export default abstract class Recognizer { 29 | abstract readonly ingKey: IngKey // dragging, scrolling, etc. 30 | protected debounced: Boolean = true 31 | abstract readonly stateKey: T 32 | 33 | /** 34 | * Creates an instance of a gesture recognizer. 35 | * @param stateKey drag, move, pinch, etc. 36 | * @param controller the controller attached to the gesture 37 | * @param [args] the args that should be passed to the gesture handler 38 | */ 39 | constructor(readonly controller: Controller, readonly args: any[] = []) { 40 | this.controller = controller 41 | this.args = args 42 | } 43 | 44 | // Returns the gesture config 45 | get config(): NonNullable { 46 | return this.controller.config[this.stateKey]! 47 | } 48 | 49 | // Is the gesture enabled 50 | get enabled(): boolean { 51 | return this.controller.config.enabled && this.config.enabled 52 | } 53 | 54 | // Returns the controller state for a given gesture 55 | get state(): GestureState { 56 | return this.controller.state[this.stateKey] 57 | } 58 | 59 | // Returns the gesture handler 60 | get handler() { 61 | return this.controller.handlers[this.stateKey]! 62 | } 63 | 64 | get transform() { 65 | return this.config.transform || this.controller.config.transform || identity 66 | } 67 | 68 | // Convenience method to update the shared state 69 | protected updateSharedState(sharedState: Partial | null) { 70 | Object.assign(this.controller.state.shared, sharedState) 71 | } 72 | 73 | // Convenience method to update the gesture state 74 | protected updateGestureState(gestureState: PartialGestureState | null) { 75 | Object.assign(this.state, gestureState) 76 | } 77 | 78 | // Convenience method to set a timeout for a given gesture 79 | protected setTimeout = ( 80 | callback: (...args: any[]) => void, 81 | ms: number = 140, 82 | ...args: any[] 83 | ): void => { 84 | clearTimeout(this.controller.timeouts[this.stateKey]) 85 | this.controller.timeouts[this.stateKey] = window.setTimeout( 86 | callback, 87 | ms, 88 | ...args, 89 | ) 90 | } 91 | 92 | // Convenience method to clear a timeout for a given gesture 93 | protected clearTimeout = () => { 94 | clearTimeout(this.controller.timeouts[this.stateKey]) 95 | } 96 | 97 | protected abstract getKinematics( 98 | values: Vector2, 99 | event: UIEvent, 100 | ): PartialGestureState 101 | 102 | protected abstract getInternalMovement( 103 | values: Vector2, 104 | state: GestureState, 105 | ): Vector2 106 | 107 | protected abstract mapStateValues( 108 | state: GestureState, 109 | ): Omit, 'event'> 110 | 111 | public abstract addBindings(bindings: any): void 112 | 113 | /** 114 | * Returns state properties depending on the movement and state. 115 | * 116 | * Should be overriden for custom behavior, doesn't do anything in the implementation 117 | * below. 118 | */ 119 | protected checkIntentionality( 120 | _intentional: [false | number, false | number], 121 | _movement: Vector2, 122 | ): PartialGestureState { 123 | return { _intentional, _blocked: false } as PartialGestureState 124 | } 125 | 126 | /** 127 | * Returns basic movement properties for the gesture based on the next values and current state. 128 | */ 129 | protected getMovement(values: Vector2): PartialGestureState { 130 | const { rubberband, threshold: T } = this.config 131 | 132 | const { 133 | _bounds, 134 | _initial, 135 | _active, 136 | _intentional: wasIntentional, 137 | lastOffset, 138 | movement: prevMovement, 139 | } = this.state 140 | const M = this.getInternalMovement(values, this.state) 141 | 142 | const _T = this.transform(T).map(Math.abs) 143 | 144 | const i0 = 145 | wasIntentional[0] === false 146 | ? getIntentionalDisplacement(M[0], _T[0]) 147 | : wasIntentional[0] 148 | const i1 = 149 | wasIntentional[1] === false 150 | ? getIntentionalDisplacement(M[1], _T[1]) 151 | : wasIntentional[1] 152 | 153 | // Get gesture specific state properties based on intentionality and movement. 154 | const intentionalityCheck = this.checkIntentionality([i0, i1], M) 155 | if (intentionalityCheck._blocked) { 156 | return { ...intentionalityCheck, _movement: M, delta: [0, 0] } 157 | } 158 | 159 | const _intentional = intentionalityCheck._intentional! 160 | const _movement = M 161 | 162 | /** 163 | * The movement sent to the handler has 0 in its dimensions when intentionality is false. 164 | * It is calculated from the actual movement minus the threshold. 165 | */ 166 | let movement: Vector2 = [ 167 | _intentional[0] !== false ? M[0] - _intentional[0] : 0, 168 | _intentional[1] !== false ? M[1] - _intentional[1] : 0, 169 | ] 170 | 171 | const offset = addV(movement, lastOffset) 172 | 173 | /** 174 | * Rubberband should be 0 when the gesture is no longer active, so that movement 175 | * and offset can return within their bounds. 176 | */ 177 | const _rubberband: Vector2 = _active ? rubberband : [0, 0] 178 | movement = computeRubberband(_bounds, addV(movement, _initial), _rubberband) 179 | 180 | return { 181 | ...intentionalityCheck, 182 | intentional: _intentional[0] !== false || _intentional[1] !== false, 183 | _initial, 184 | _movement, 185 | movement, 186 | values, 187 | offset: computeRubberband(_bounds, offset, _rubberband), 188 | delta: subV(movement, prevMovement), 189 | } as PartialGestureState 190 | } 191 | 192 | // Cleans the gesture. Can be overriden by gestures. 193 | protected clean() { 194 | this.clearTimeout() 195 | } 196 | 197 | /** 198 | * Fires the gesture handler 199 | */ 200 | protected fireGestureHandler = ( 201 | forceFlag: boolean = false, 202 | ): FullGestureState | null => { 203 | /** 204 | * If the gesture has been blocked (this can happen when the gesture has started in an unwanted direction), 205 | * clean everything and don't do anything. 206 | */ 207 | if (this.state._blocked) { 208 | // we need debounced gestures to end by themselves 209 | if (!this.debounced) { 210 | this.state._active = false 211 | this.clean() 212 | } 213 | return null 214 | } 215 | 216 | // If the gesture has no intentional dimension, don't fire the handler. 217 | if (!forceFlag && !this.state.intentional && !this.config.triggerAllEvents) 218 | return null 219 | 220 | if (this.state.intentional) { 221 | const prev_active = this.state.active 222 | const next_active = this.state._active 223 | 224 | this.state.active = next_active 225 | this.state.first = next_active && !prev_active 226 | this.state.last = prev_active && !next_active 227 | 228 | this.controller.state.shared[this.ingKey] = next_active // Sets dragging, pinching, etc. to the gesture active state 229 | } 230 | const touches = 231 | this.controller.pointerIds.size || this.controller.touchIds.size 232 | const down = this.controller.state.shared.buttons > 0 || touches > 0 233 | 234 | const state = { 235 | ...this.controller.state.shared, 236 | ...this.state, 237 | ...this.mapStateValues(this.state), // Sets xy or da to the gesture state values 238 | locked: !!document.pointerLockElement, 239 | touches, 240 | down, 241 | } as FullGestureState 242 | 243 | // @ts-expect-error 244 | const newMemo = this.handler(state) 245 | 246 | // Sets memo to the returned value of the handler (unless it's not undefined) 247 | this.state.memo = newMemo !== void 0 ? newMemo : this.state.memo 248 | 249 | return state 250 | } 251 | } 252 | 253 | //-------------------------------------------- 254 | 255 | function getIntentionalDisplacement( 256 | movement: number, 257 | threshold: number, 258 | ): number | false { 259 | if (Math.abs(movement) >= threshold) { 260 | return sign(movement) * threshold 261 | } else { 262 | return false 263 | } 264 | } 265 | 266 | function computeRubberband( 267 | bounds: [Vector2, Vector2], 268 | [Vx, Vy]: Vector2, 269 | [Rx, Ry]: Vector2, 270 | ): Vector2 { 271 | const [[X1, X2], [Y1, Y2]] = bounds 272 | 273 | return [ 274 | rubberbandIfOutOfBounds(Vx, X1, X2, Rx), 275 | rubberbandIfOutOfBounds(Vy, Y1, Y2, Ry), 276 | ] 277 | } 278 | 279 | /** 280 | * Returns a generic, common payload for all gestures from an event. 281 | */ 282 | export function getGenericPayload( 283 | { state }: Recognizer, 284 | event: EventTypes[T], 285 | isStartEvent?: boolean, 286 | ) { 287 | const { timeStamp, type: _lastEventType } = event 288 | const previous = state.values 289 | const elapsedTime = isStartEvent ? 0 : timeStamp - state.startTime! 290 | return { _lastEventType, event, timeStamp, elapsedTime, previous } 291 | } 292 | 293 | /** 294 | * Returns the reinitialized start state for the gesture. 295 | * Should be common to all gestures. 296 | */ 297 | export function getStartGestureState( 298 | { state, config, stateKey, args }: Recognizer, 299 | values: Vector2, 300 | event: EventTypes[T], 301 | ) { 302 | const offset = state.offset 303 | const startTime = event.timeStamp 304 | 305 | const { initial, bounds } = config 306 | 307 | const _state = { 308 | ...getInitialState()[stateKey], 309 | _active: true, 310 | args, 311 | values, 312 | initial: values, 313 | offset, 314 | lastOffset: offset, 315 | startTime, 316 | } 317 | 318 | return { 319 | ..._state, 320 | _initial: valueFn(initial, _state), 321 | _bounds: valueFn(bounds, _state), 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /src/recognizers/ScrollRecognizer.ts: -------------------------------------------------------------------------------- 1 | import { addBindings } from '../Controller' 2 | import { getGenericEventData, getScrollEventValues } from '../utils/event' 3 | import { calculateAllGeometry } from '../utils/math' 4 | import CoordinatesRecognizer from './CoordinatesRecognizer' 5 | import { getGenericPayload, getStartGestureState } from './Recognizer' 6 | 7 | export class ScrollRecognizer extends CoordinatesRecognizer<'scroll'> { 8 | readonly ingKey = 'scrolling' 9 | readonly stateKey = 'scroll' 10 | debounced = true 11 | 12 | handleEvent = (event: UIEvent): void => { 13 | if (!this.enabled) return 14 | 15 | this.clearTimeout() 16 | this.setTimeout(this.onEnd) 17 | 18 | const values = getScrollEventValues(event, this.transform) 19 | this.updateSharedState(getGenericEventData(event)) 20 | 21 | if (!this.state._active) { 22 | this.updateGestureState({ 23 | ...getStartGestureState(this, values, event), 24 | ...getGenericPayload(this, event, true), 25 | initial: this.state.values, 26 | }) 27 | 28 | const movementDetection = this.getMovement(values) 29 | const geometry = calculateAllGeometry(movementDetection.delta!) 30 | 31 | this.updateGestureState(movementDetection) 32 | this.updateGestureState(geometry) 33 | } else { 34 | this.updateGestureState({ 35 | ...getGenericPayload(this, event), 36 | ...this.getKinematics(values, event), 37 | }) 38 | } 39 | 40 | this.fireGestureHandler() 41 | } 42 | 43 | onEnd = (): void => { 44 | this.clean() 45 | if (!this.state._active) return 46 | this.updateGestureState({ 47 | ...this.getMovement(this.state.values), 48 | _active: false, 49 | velocities: [0, 0], 50 | velocity: 0, 51 | }) 52 | 53 | this.fireGestureHandler() 54 | } 55 | 56 | addBindings(bindings: any): void { 57 | addBindings(bindings, 'onScroll', this.handleEvent) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/recognizers/WheelRecognizer.ts: -------------------------------------------------------------------------------- 1 | import { addBindings } from '../Controller' 2 | import { getGenericEventData, getWheelEventValues } from '../utils/event' 3 | import { addV, calculateAllGeometry } from '../utils/math' 4 | import CoordinatesRecognizer from './CoordinatesRecognizer' 5 | import { getGenericPayload, getStartGestureState } from './Recognizer' 6 | export class WheelRecognizer extends CoordinatesRecognizer<'wheel'> { 7 | readonly ingKey = 'wheeling' 8 | readonly stateKey = 'wheel' 9 | debounced = true 10 | 11 | handleEvent = (event: WheelEvent): void => { 12 | if (event.ctrlKey && 'pinch' in this.controller.handlers) return 13 | if (!this.enabled) return 14 | 15 | this.setTimeout(this.onEnd) 16 | this.updateSharedState(getGenericEventData(event)) 17 | 18 | const values = addV( 19 | getWheelEventValues(event, this.transform), 20 | this.state.values, 21 | ) 22 | 23 | if (!this.state._active) { 24 | this.updateGestureState({ 25 | ...getStartGestureState(this, values, event), 26 | ...getGenericPayload(this, event, true), 27 | initial: this.state.values, 28 | }) 29 | 30 | const movement = this.getMovement(values) 31 | const geometry = calculateAllGeometry(movement.delta!) 32 | 33 | this.updateGestureState(movement) 34 | this.updateGestureState(geometry) 35 | } else { 36 | this.updateGestureState({ 37 | ...getGenericPayload(this, event), 38 | ...this.getKinematics(values, event), 39 | }) 40 | } 41 | 42 | this.fireGestureHandler() 43 | } 44 | 45 | onEnd = (): void => { 46 | this.clean() 47 | if (!this.state._active) return 48 | const movement = this.getMovement(this.state.values) 49 | this.updateGestureState(movement) 50 | this.updateGestureState({ _active: false, velocities: [0, 0], velocity: 0 }) 51 | this.fireGestureHandler() 52 | } 53 | 54 | addBindings(bindings: any): void { 55 | addBindings(bindings, 'onWheel', this.handleEvent) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | import Controller from '../Controller' 2 | 3 | // Global compile-time constants 4 | declare var __DEV__: boolean 5 | declare var __BROWSER__: boolean 6 | declare var __CI__: boolean 7 | 8 | type GestureHandlersMap = { 9 | drag?: Controller 10 | hover?: Controller 11 | move?: Controller 12 | pinch?: Controller 13 | scroll?: Controller 14 | wheel?: Controller 15 | [key: string]: Controller 16 | } 17 | 18 | declare global { 19 | interface HTMLElement { 20 | gestures?: GestureHandlersMap 21 | } 22 | 23 | interface SVGElement { 24 | gestures?: GestureHandlersMap 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { ComputedRef, HTMLAttributes, Ref } from 'vue-demi' 2 | import type Controller from '../Controller' 3 | import type Recognizer from '../recognizers/Recognizer' 4 | 5 | export type MaybeRef = T | Ref | ComputedRef 6 | 7 | export type GestureTarget = HTMLElement | SVGElement | null | undefined 8 | 9 | export type Omit = Pick> 10 | 11 | export type AtLeastOneOf }> = Partial & 12 | U[keyof U] 13 | 14 | export type Vector2 = [number, number] 15 | 16 | export type Fn = any 17 | 18 | export interface GenericOptions { 19 | domTarget: MaybeRef 20 | window?: MaybeRef 21 | eventOptions?: { capture?: boolean; passive?: boolean } 22 | enabled?: boolean 23 | transform?: (v: Vector2) => Vector2 24 | } 25 | 26 | export interface GestureOptions { 27 | enabled?: boolean 28 | initial?: Vector2 | ((state: State[T]) => Vector2) 29 | threshold?: number | Vector2 30 | triggerAllEvents?: boolean 31 | rubberband?: boolean | number | Vector2 32 | transform?: (v: Vector2) => Vector2 33 | } 34 | 35 | export type Bounds = { 36 | top?: number 37 | bottom?: number 38 | left?: number 39 | right?: number 40 | } 41 | 42 | export type CoordinatesConfig = GestureOptions & { 43 | axis?: 'x' | 'y' 44 | lockDirection?: boolean 45 | bounds?: Bounds | ((state: State[T]) => Bounds) 46 | } 47 | 48 | export type DistanceAngleBounds = { min?: number; max?: number } 49 | 50 | export type DistanceAngleConfig< 51 | T extends DistanceAngleKey 52 | > = GestureOptions & { 53 | distanceBounds?: 54 | | DistanceAngleBounds 55 | | ((state: State[T]) => DistanceAngleBounds) 56 | angleBounds?: DistanceAngleBounds | ((state: State[T]) => DistanceAngleBounds) 57 | } 58 | 59 | export type DragConfig = CoordinatesConfig<'drag'> & { 60 | filterTaps?: boolean 61 | useTouch?: boolean 62 | swipeVelocity?: number | Vector2 63 | swipeDistance?: number | Vector2 64 | swipeDuration?: number 65 | preventWindowScrollY?: boolean 66 | delay?: boolean | number 67 | } 68 | 69 | export type UseDragConfig = GenericOptions & DragConfig 70 | export type UsePinchConfig = GenericOptions & DistanceAngleConfig<'pinch'> 71 | export type UseWheelConfig = GenericOptions & CoordinatesConfig<'wheel'> 72 | export type UseScrollConfig = GenericOptions & CoordinatesConfig<'scroll'> 73 | export type UseMoveConfig = GenericOptions & CoordinatesConfig<'move'> 74 | export type UseHoverConfig = GenericOptions 75 | 76 | export type UseGestureConfig = GenericOptions & { 77 | drag?: DragConfig 78 | wheel?: CoordinatesConfig<'wheel'> 79 | scroll?: CoordinatesConfig<'scroll'> 80 | move?: CoordinatesConfig<'move'> 81 | pinch?: DistanceAngleConfig<'pinch'> 82 | hover?: { enabled?: boolean } 83 | } 84 | 85 | export interface InternalGenericOptions { 86 | domTarget: MaybeRef 87 | manual?: boolean 88 | window?: MaybeRef 89 | eventOptions: { capture?: boolean; passive?: boolean } 90 | enabled: boolean 91 | transform?: (v: Vector2) => Vector2 92 | } 93 | 94 | export interface InternalGestureOptions { 95 | enabled: boolean 96 | initial: Vector2 | ((state: State[T]) => Vector2) 97 | threshold: Vector2 98 | triggerAllEvents: boolean 99 | rubberband: Vector2 100 | bounds: [Vector2, Vector2] | ((state: State[T]) => [Vector2, Vector2]) 101 | transform?: (v: Vector2) => Vector2 102 | } 103 | 104 | export interface InternalCoordinatesOptions 105 | extends InternalGestureOptions { 106 | axis?: 'x' | 'y' 107 | lockDirection: boolean 108 | } 109 | 110 | export interface InternalDistanceAngleOptions 111 | extends InternalGestureOptions {} 112 | 113 | export interface InternalDragOptions 114 | extends InternalCoordinatesOptions<'drag'> { 115 | filterTaps: boolean 116 | useTouch: boolean 117 | preventWindowScrollY: boolean 118 | swipeVelocity: Vector2 119 | swipeDistance: Vector2 120 | swipeDuration: number 121 | delay: number 122 | } 123 | 124 | export type InternalConfig = InternalGenericOptions & { 125 | drag?: InternalDragOptions 126 | wheel?: InternalCoordinatesOptions<'wheel'> 127 | scroll?: InternalCoordinatesOptions<'scroll'> 128 | move?: InternalCoordinatesOptions<'move'> 129 | pinch?: InternalDistanceAngleOptions<'pinch'> 130 | hover?: { enabled: boolean; transform?: (v: Vector2) => Vector2 } 131 | } 132 | 133 | export type WebKitGestureEvent = PointerEvent & { 134 | scale: number 135 | rotation: number 136 | } 137 | 138 | export type DomEvents = 139 | | TouchEvent 140 | | PointerEvent 141 | | WheelEvent 142 | | UIEvent 143 | | WebKitGestureEvent 144 | 145 | export interface EventHandlers { 146 | // Mouse Events 147 | onMouseDown?: Fn 148 | onMouseDownCapture?: Fn 149 | onMouseEnter?: Fn 150 | onMouseLeave?: Fn 151 | onMouseMove?: Fn 152 | onMouseMoveCapture?: Fn 153 | onMouseOut?: Fn 154 | onMouseOutCapture?: Fn 155 | onMouseOver?: Fn 156 | onMouseOverCapture?: Fn 157 | onMouseUp?: Fn 158 | onMouseUpCapture?: Fn 159 | // Touch Events 160 | onTouchCancel?: Fn 161 | onTouchCancelCapture?: Fn 162 | onTouchEnd?: Fn 163 | onTouchEndCapture?: Fn 164 | onTouchMove?: Fn 165 | onTouchMoveCapture?: Fn 166 | onTouchStart?: Fn 167 | onTouchStartCapture?: Fn 168 | 169 | // Pointer Events 170 | onPointerDown?: Fn 171 | onPointerDownCapture?: Fn 172 | onPointerMove?: Fn 173 | onPointerMoveCapture?: Fn 174 | onPointerUp?: Fn 175 | onPointerUpCapture?: Fn 176 | onPointerCancel?: Fn 177 | onPointerCancelCapture?: Fn 178 | onPointerEnter?: Fn 179 | onPointerEnterCapture?: Fn 180 | onPointerLeave?: Fn 181 | onPointerLeaveCapture?: Fn 182 | onPointerOver?: Fn 183 | onPointerOverCapture?: Fn 184 | onPointerOut?: Fn 185 | onPointerOutCapture?: Fn 186 | onGotPointerCapture?: Fn 187 | onGotPointerCaptureCapture?: Fn 188 | onLostPointerCapture?: Fn 189 | onLostPointerCaptureCapture?: Fn 190 | 191 | // UI Events 192 | onScroll?: Fn 193 | onScrollCapture?: Fn 194 | 195 | // Wheel Events 196 | onWheel?: Fn 197 | onWheelCapture?: Fn 198 | 199 | // Cheat mode for Gesture Events 200 | onGestureStart?: Fn 201 | onGestureChange?: Fn 202 | onGestureEnd?: Fn 203 | 204 | // onClick for drag / filterTaps 205 | onClick?: Fn 206 | onClickCapture?: Fn 207 | } 208 | 209 | export type EventHandlersKey = keyof EventHandlers 210 | 211 | export type IngKey = 212 | | 'hovering' 213 | | 'scrolling' 214 | | 'wheeling' 215 | | 'dragging' 216 | | 'moving' 217 | | 'pinching' 218 | export type CoordinatesKey = 'drag' | 'wheel' | 'move' | 'scroll' 219 | export type DistanceAngleKey = 'pinch' 220 | export type GestureKey = CoordinatesKey | DistanceAngleKey | 'hover' 221 | export type StateKey = T extends 'hover' 222 | ? 'move' 223 | : T 224 | 225 | export type SharedGestureState = { [ingKey in IngKey]: boolean } & { 226 | touches: number 227 | down: boolean 228 | buttons: number 229 | shiftKey: boolean 230 | altKey: boolean 231 | metaKey: boolean 232 | ctrlKey: boolean 233 | locked: boolean 234 | } 235 | 236 | export type EventTypes = { 237 | drag: PointerEvent 238 | wheel: WheelEvent 239 | scroll: UIEvent 240 | move: PointerEvent 241 | pinch: TouchEvent | WheelEvent | WebKitGestureEvent 242 | hover: PointerEvent 243 | } 244 | 245 | export interface CommonGestureState { 246 | _active: boolean 247 | _blocked: boolean 248 | _intentional: [false | number, false | number] 249 | _movement: Vector2 250 | _initial: Vector2 251 | _bounds: [Vector2, Vector2] 252 | _lastEventType?: string 253 | _dragTarget?: GestureTarget | null 254 | _dragPointerId?: number | null 255 | _dragStarted: boolean 256 | _dragPreventScroll: boolean 257 | _dragIsTap: boolean 258 | _dragDelayed: boolean 259 | event?: UIEvent 260 | intentional: boolean 261 | values: Vector2 262 | velocities: Vector2 263 | delta: Vector2 264 | movement: Vector2 265 | offset: Vector2 266 | lastOffset: Vector2 267 | initial: Vector2 268 | previous: Vector2 269 | direction: Vector2 270 | first: boolean 271 | last: boolean 272 | active: boolean 273 | startTime: number 274 | timeStamp: number 275 | elapsedTime: number 276 | cancel(): void 277 | canceled: boolean 278 | memo?: any 279 | args?: any 280 | } 281 | 282 | export interface Coordinates { 283 | axis?: 'x' | 'y' 284 | xy: Vector2 285 | velocity: number 286 | vxvy: Vector2 287 | distance: number 288 | } 289 | 290 | export interface DragState { 291 | _pointerId?: number 292 | tap: boolean 293 | swipe: Vector2 294 | } 295 | 296 | export interface PinchState { 297 | _pointerIds: [number, number] 298 | } 299 | 300 | export interface DistanceAngle { 301 | da: Vector2 302 | vdva: Vector2 303 | origin: Vector2 304 | turns: number 305 | } 306 | 307 | export type State = { 308 | shared: SharedGestureState 309 | drag: CommonGestureState & Coordinates & DragState 310 | wheel: CommonGestureState & Coordinates 311 | scroll: CommonGestureState & Coordinates 312 | move: CommonGestureState & Coordinates 313 | pinch: CommonGestureState & DistanceAngle & PinchState 314 | } 315 | 316 | export type GestureState = State[T] 317 | export type PartialGestureState = Partial> 318 | 319 | export type FullGestureState = SharedGestureState & State[T] 320 | 321 | export type Handler = ( 322 | state: Omit>, 'event'> & { event: K }, 323 | ) => any | void 324 | 325 | export type InternalHandlers = { [Key in GestureKey]?: Handler } 326 | 327 | type NativeHandlersKeys = keyof Omit< 328 | HTMLAttributes, 329 | keyof UserHandlers & keyof HTMLAttributes 330 | > 331 | 332 | // allows overriding the event type from the returned state in handlers 333 | export type AnyGestureEventTypes = Partial< 334 | { 335 | drag: any 336 | wheel: any 337 | scroll: any 338 | move: any 339 | pinch: any 340 | hover: any 341 | } & { [key in NativeHandlersKeys]: any } 342 | > 343 | 344 | // if no type is provided in the user generic for a given key 345 | // then return the default EventTypes that key 346 | type check< 347 | T extends AnyGestureEventTypes, 348 | Key extends GestureKey 349 | > = undefined extends T[Key] ? EventTypes[Key] : T[Key] 350 | 351 | export type UserHandlers = { 352 | onDrag: Handler<'drag', check> 353 | onDragStart: Handler<'drag', check> 354 | onDragEnd: Handler<'drag', check> 355 | onPinch: Handler<'pinch', check> 356 | onPinchStart: Handler<'pinch', check> 357 | onPinchEnd: Handler<'pinch', check> 358 | onWheel: Handler<'wheel', check> 359 | onWheelStart: Handler<'wheel', check> 360 | onWheelEnd: Handler<'wheel', check> 361 | onMove: Handler<'move', check> 362 | onMoveStart: Handler<'move', check> 363 | onMoveEnd: Handler<'move', check> 364 | onScroll: Handler<'scroll', check> 365 | onScrollStart: Handler<'scroll', check> 366 | onScrollEnd: Handler<'scroll', check> 367 | onHover: Handler<'hover', check> 368 | } 369 | 370 | export type RecognizerClass = { 371 | new (controller: Controller, args: any): Recognizer 372 | } 373 | 374 | export type NativeHandlers = { 375 | [key in NativeHandlersKeys]: ( 376 | state: SharedGestureState & { 377 | event: undefined extends T[key] ? Event : T[key] 378 | args: any 379 | }, 380 | ...args: any 381 | ) => void 382 | } 383 | 384 | export type Handlers = Partial< 385 | UserHandlers & NativeHandlers 386 | > 387 | 388 | export type HookReturnType< 389 | T extends { domTarget: MaybeRef } 390 | > = T['domTarget'] 391 | -------------------------------------------------------------------------------- /src/utils/config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Bounds, 3 | CoordinatesConfig, 4 | CoordinatesKey, 5 | DistanceAngleConfig, 6 | DistanceAngleKey, 7 | DragConfig, 8 | GenericOptions, 9 | GestureOptions, 10 | InternalCoordinatesOptions, 11 | InternalDistanceAngleOptions, 12 | InternalDragOptions, 13 | InternalGenericOptions, 14 | InternalGestureOptions, 15 | State, 16 | StateKey, 17 | Vector2, 18 | } from '../types' 19 | import { supportsTouchEvents } from './event' 20 | import { resolveWith } from './resolveOptionsWith' 21 | import { assignDefault, ensureVector, valueFn } from './utils' 22 | 23 | export const DEFAULT_DRAG_DELAY = 180 24 | export const DEFAULT_RUBBERBAND = 0.15 25 | export const DEFAULT_SWIPE_VELOCITY = 0.5 26 | export const DEFAULT_SWIPE_DISTANCE = 50 27 | export const DEFAULT_SWIPE_DURATION = 250 28 | 29 | const InternalGestureOptionsNormalizers = { 30 | threshold(value: number | Vector2 = 0) { 31 | return ensureVector(value) 32 | }, 33 | 34 | rubberband(value: number | boolean | Vector2 = 0): Vector2 { 35 | switch (value) { 36 | case true: 37 | return ensureVector(DEFAULT_RUBBERBAND) 38 | case false: 39 | return ensureVector(0) 40 | default: 41 | return ensureVector(value) 42 | } 43 | }, 44 | 45 | enabled(value = true) { 46 | return value 47 | }, 48 | 49 | triggerAllEvents(value = false) { 50 | return value 51 | }, 52 | 53 | initial(value = 0) { 54 | if (typeof value === 'function') return value 55 | return ensureVector(value) 56 | }, 57 | 58 | transform: true, 59 | } 60 | 61 | const InternalCoordinatesOptionsNormalizers = { 62 | ...InternalGestureOptionsNormalizers, 63 | axis: true, 64 | lockDirection(value = false) { 65 | return value 66 | }, 67 | bounds(value: Bounds | ((state?: State) => Bounds) = {}) { 68 | if (typeof value === 'function') 69 | return (state?: State) => 70 | InternalCoordinatesOptionsNormalizers.bounds(value(state)) 71 | 72 | const { 73 | left = -Infinity, 74 | right = Infinity, 75 | top = -Infinity, 76 | bottom = Infinity, 77 | } = value 78 | 79 | return [ 80 | [left, right], 81 | [top, bottom], 82 | ] 83 | }, 84 | } 85 | 86 | const isBrowser = 87 | typeof window !== 'undefined' && 88 | window.document && 89 | window.document.createElement 90 | 91 | const InternalGenericOptionsNormalizers = { 92 | enabled(value = true) { 93 | return value 94 | }, 95 | domTarget: true, 96 | window(value = isBrowser ? window : undefined) { 97 | return value 98 | }, 99 | eventOptions({ passive = true, capture = false } = {}) { 100 | return { passive, capture } 101 | }, 102 | transform: true, 103 | } 104 | 105 | const InternalDistanceAngleOptionsNormalizers = { 106 | ...InternalGestureOptionsNormalizers, 107 | 108 | bounds( 109 | _value: undefined, 110 | _key: string, 111 | { distanceBounds = {}, angleBounds = {} }, 112 | ) { 113 | const _distanceBounds = (state?: State) => { 114 | const D = assignDefault(valueFn(distanceBounds, state), { 115 | min: -Infinity, 116 | max: Infinity, 117 | }) 118 | return [D.min, D.max] 119 | } 120 | 121 | const _angleBounds = (state?: State) => { 122 | const A = assignDefault(valueFn(angleBounds, state), { 123 | min: -Infinity, 124 | max: Infinity, 125 | }) 126 | return [A.min, A.max] 127 | } 128 | 129 | if ( 130 | typeof distanceBounds !== 'function' && 131 | typeof angleBounds !== 'function' 132 | ) 133 | return [_distanceBounds(), _angleBounds()] 134 | 135 | return (state?: State) => [_distanceBounds(state), _angleBounds(state)] 136 | }, 137 | } 138 | 139 | const InternalDragOptionsNormalizers = { 140 | ...InternalCoordinatesOptionsNormalizers, 141 | 142 | useTouch(value = true) { 143 | return value && supportsTouchEvents() 144 | }, 145 | preventWindowScrollY(value = false) { 146 | return value 147 | }, 148 | threshold( 149 | this: InternalDragOptions, 150 | v: number | Vector2 | undefined, 151 | _k: string, 152 | { filterTaps = false, lockDirection = false, axis = undefined }, 153 | ) { 154 | const A = ensureVector( 155 | v, 156 | filterTaps ? 3 : lockDirection ? 1 : axis ? 1 : 0, 157 | ) as Vector2 158 | this.filterTaps = filterTaps 159 | return A 160 | }, 161 | 162 | swipeVelocity(v: number | Vector2 = DEFAULT_SWIPE_VELOCITY) { 163 | return ensureVector(v) 164 | }, 165 | swipeDistance(v: number | Vector2 = DEFAULT_SWIPE_DISTANCE) { 166 | return ensureVector(v) 167 | }, 168 | swipeDuration(value = DEFAULT_SWIPE_DURATION) { 169 | return value 170 | }, 171 | delay(value: number | boolean = 0) { 172 | switch (value) { 173 | case true: 174 | return DEFAULT_DRAG_DELAY 175 | case false: 176 | return 0 177 | default: 178 | return value 179 | } 180 | }, 181 | } 182 | 183 | export function getInternalGenericOptions( 184 | config: GenericOptions, 185 | ): InternalGenericOptions { 186 | return resolveWith( 187 | config, 188 | InternalGenericOptionsNormalizers, 189 | ) 190 | } 191 | 192 | export function getInternalGestureOptions( 193 | config: GestureOptions = {}, 194 | ): InternalGestureOptions { 195 | return resolveWith, InternalGestureOptions>( 196 | config, 197 | InternalGestureOptionsNormalizers, 198 | ) 199 | } 200 | 201 | export function getInternalCoordinatesOptions( 202 | config: CoordinatesConfig = {}, 203 | ): InternalCoordinatesOptions { 204 | return resolveWith, InternalCoordinatesOptions>( 205 | config, 206 | InternalCoordinatesOptionsNormalizers, 207 | ) 208 | } 209 | 210 | export function getInternalDistanceAngleOptions( 211 | config: DistanceAngleConfig = {}, 212 | ): InternalDistanceAngleOptions { 213 | return resolveWith, InternalDistanceAngleOptions>( 214 | config, 215 | InternalDistanceAngleOptionsNormalizers, 216 | ) 217 | } 218 | 219 | export function getInternalDragOptions( 220 | config: DragConfig = {}, 221 | ): InternalDragOptions { 222 | return resolveWith( 223 | config, 224 | InternalDragOptionsNormalizers, 225 | ) 226 | } 227 | -------------------------------------------------------------------------------- /src/utils/event.ts: -------------------------------------------------------------------------------- 1 | import { DomEvents, Vector2, WebKitGestureEvent } from '../types' 2 | 3 | /** 4 | * Whether the browser supports GestureEvent (ie Safari) 5 | * @returns true if the browser supports gesture event 6 | */ 7 | export function supportsGestureEvents(): boolean { 8 | try { 9 | // TODO [TS] possibly find GestureEvent definitions? 10 | // @ts-ignore: no type definitions for webkit GestureEvents 11 | return 'constructor' in GestureEvent 12 | } catch (e) { 13 | return false 14 | } 15 | } 16 | 17 | export function supportsTouchEvents(): boolean { 18 | return typeof window !== 'undefined' && 'ontouchstart' in window 19 | } 20 | 21 | function getEventTouches(event: PointerEvent | TouchEvent) { 22 | if ('pointerId' in event) return null 23 | return event.type === 'touchend' ? event.changedTouches : event.targetTouches 24 | } 25 | 26 | export function getTouchIds(event: TouchEvent): number[] { 27 | return Array.from(getEventTouches(event)!).map((t) => t.identifier) 28 | } 29 | 30 | export function getGenericEventData(event: DomEvents) { 31 | const buttons = 'buttons' in event ? event.buttons : 0 32 | const { shiftKey, altKey, metaKey, ctrlKey } = event as any // TODO check if this might create some overrides? 33 | return { buttons, shiftKey, altKey, metaKey, ctrlKey } 34 | } 35 | 36 | const identity = (xy: Vector2) => xy 37 | 38 | /** 39 | * Gets pointer event values. 40 | * @param event 41 | * @returns pointer event values 42 | */ 43 | export function getPointerEventValues( 44 | event: TouchEvent | PointerEvent, 45 | transform = identity, 46 | ): Vector2 { 47 | const touchEvents = getEventTouches(event) 48 | const { clientX, clientY } = touchEvents 49 | ? touchEvents[0] 50 | : (event as PointerEvent) 51 | return transform([clientX, clientY]) 52 | } 53 | 54 | /** 55 | * Gets two touches event data 56 | * @param event 57 | * @returns two touches event data 58 | */ 59 | export function getTwoTouchesEventValues( 60 | event: TouchEvent, 61 | pointerIds: [number, number], 62 | transform = identity, 63 | ) { 64 | const [A, B] = Array.from(event.touches).filter((t) => 65 | pointerIds.includes(t.identifier), 66 | ) 67 | 68 | if (!A || !B) 69 | throw Error(`The event doesn't have two pointers matching the pointerIds`) 70 | 71 | const dx = B.clientX - A.clientX 72 | const dy = B.clientY - A.clientY 73 | const cx = (B.clientX + A.clientX) / 2 74 | const cy = (B.clientY + A.clientY) / 2 75 | 76 | // const e: any = 'nativeEvent' in event ? event.nativeEvent : event 77 | 78 | const distance = Math.hypot(dx, dy) 79 | // FIXME rotation has inconsistant values so we're not using it atm 80 | // const angle = (e.rotation as number) ?? -(Math.atan2(dx, dy) * 180) / Math.PI 81 | const angle = -(Math.atan2(dx, dy) * 180) / Math.PI 82 | const values: Vector2 = transform([distance, angle]) 83 | const origin: Vector2 = transform([cx, cy]) 84 | 85 | return { values, origin } 86 | } 87 | 88 | /** 89 | * Gets scroll event values 90 | * @param event 91 | * @returns scroll event values 92 | */ 93 | export function getScrollEventValues( 94 | event: UIEvent, 95 | transform = identity, 96 | ): Vector2 { 97 | // If the currentTarget is the window then we return the scrollX/Y position. 98 | // If not (ie the currentTarget is a DOM element), then we return scrollLeft/Top 99 | const { 100 | scrollX, 101 | scrollY, 102 | scrollLeft, 103 | scrollTop, 104 | } = event.currentTarget as Element & Window 105 | return transform([scrollX || scrollLeft || 0, scrollY || scrollTop || 0]) 106 | } 107 | 108 | // wheel delta defaults from https://github.com/facebookarchive/fixed-data-table/blob/master/src/vendor_upstream/dom/normalizeWheel.js 109 | const LINE_HEIGHT = 40 110 | const PAGE_HEIGHT = 800 111 | 112 | /** 113 | * Gets wheel event values. 114 | * @param event 115 | * @returns wheel event values 116 | */ 117 | export function getWheelEventValues( 118 | event: WheelEvent, 119 | transform = identity, 120 | ): Vector2 { 121 | let { deltaX, deltaY, deltaMode } = event 122 | // normalize wheel values, especially for Firefox 123 | if (deltaMode === 1) { 124 | deltaX *= LINE_HEIGHT 125 | deltaY *= LINE_HEIGHT 126 | } else if (deltaMode === 2) { 127 | deltaX *= PAGE_HEIGHT 128 | deltaY *= PAGE_HEIGHT 129 | } 130 | return transform([deltaX, deltaY]) 131 | } 132 | 133 | /** 134 | * Gets webkit gesture event values. 135 | * @param event 136 | * @returns webkit gesture event values 137 | */ 138 | export function getWebkitGestureEventValues( 139 | event: WebKitGestureEvent, 140 | transform = identity, 141 | ): Vector2 { 142 | return transform([event.scale, event.rotation]) 143 | } 144 | -------------------------------------------------------------------------------- /src/utils/math.ts: -------------------------------------------------------------------------------- 1 | // vector add 2 | export function addV(v1: T, v2: T): T { 3 | return v1.map((v, i) => v + v2[i]) as T 4 | } 5 | 6 | // vector substract 7 | export function subV(v1: T, v2: T): T { 8 | return v1.map((v, i) => v - v2[i]) as T 9 | } 10 | 11 | /** 12 | * Calculates distance 13 | * @param movement the difference between current and initial vectors 14 | * @returns distance 15 | */ 16 | export function calculateDistance(movement: number[]): number { 17 | return Math.hypot(...movement) 18 | } 19 | 20 | interface Kinematics { 21 | velocities: number[] 22 | velocity: number 23 | distance: number 24 | direction: number[] 25 | } 26 | 27 | export function calculateAllGeometry( 28 | movement: T, 29 | delta: T = movement, 30 | ) { 31 | const dl = calculateDistance(delta) 32 | 33 | const alpha = dl === 0 ? 0 : 1 / dl 34 | 35 | const direction = delta.map((v) => alpha * v) as T 36 | const distance = calculateDistance(movement) 37 | 38 | return { distance, direction } 39 | } 40 | 41 | /** 42 | * Calculates all kinematics 43 | * @template T the expected vector type 44 | * @param movement the difference between current and initial vectors 45 | * @param delta the difference between current and previous vectors 46 | * @param delta_t the time difference between current and previous timestamps 47 | * @returns all kinematics 48 | */ 49 | export function calculateAllKinematics( 50 | movement: T, 51 | delta: T, 52 | dt: number, 53 | ): Kinematics { 54 | const dl = calculateDistance(delta) 55 | 56 | const alpha = dl === 0 ? 0 : 1 / dl 57 | const beta = dt === 0 ? 0 : 1 / dt 58 | 59 | const velocity = beta * dl 60 | const velocities = delta.map((v) => beta * v) 61 | const direction = delta.map((v) => alpha * v) 62 | const distance = calculateDistance(movement) 63 | 64 | return { velocities, velocity, distance, direction } 65 | } 66 | 67 | /** 68 | * Because IE doesn't support `Math.sign` function, so we use the polyfill version of the function. 69 | * This polyfill function is suggested by Mozilla: 70 | * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/sign#Polyfill 71 | * @param x target number 72 | */ 73 | export function sign(x: number) { 74 | if (Math.sign) return Math.sign(x) 75 | return Number(x > 0) - Number(x < 0) || +x 76 | } 77 | -------------------------------------------------------------------------------- /src/utils/memoize-one.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Inlined from https://github.com/alexreardon/memoize-one 3 | */ 4 | 5 | export type EqualityFn = (newArgs: any[], lastArgs: any[]) => boolean 6 | 7 | export default function memoizeOne< 8 | ResultFn extends (this: any, ...newArgs: any[]) => ReturnType 9 | >(resultFn: ResultFn, isEqual: EqualityFn): ResultFn { 10 | let lastThis: unknown 11 | let lastArgs: unknown[] = [] 12 | let lastResult: ReturnType 13 | let calledOnce: boolean = false 14 | 15 | function memoized( 16 | this: unknown, 17 | ...newArgs: unknown[] 18 | ): ReturnType { 19 | if (calledOnce && lastThis === this && isEqual(newArgs, lastArgs)) { 20 | return lastResult 21 | } 22 | 23 | lastResult = resultFn.apply(this, newArgs) 24 | calledOnce = true 25 | lastThis = this 26 | lastArgs = newArgs 27 | return lastResult 28 | } 29 | 30 | return memoized as ResultFn 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/react-fast-compare.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Taken from https://github.com/FormidableLabs/react-fast-compare 3 | * 4 | * Dropped comments and ArrayBuffer handling 5 | */ 6 | 7 | function equal(a: any, b: any): boolean { 8 | if (a === b) return true 9 | 10 | if (a && b && typeof a == 'object' && typeof b == 'object') { 11 | if (a.constructor !== b.constructor) return false 12 | 13 | let length, i, keys 14 | if (Array.isArray(a)) { 15 | length = a.length 16 | if (length !== b.length) return false 17 | for (i = length; i-- !== 0; ) if (!equal(a[i], b[i])) return false 18 | return true 19 | } 20 | 21 | let it 22 | if (typeof Map === 'function' && a instanceof Map && b instanceof Map) { 23 | if (a.size !== b.size) return false 24 | it = a.entries() 25 | while (!(i = it.next()).done) if (!b.has(i.value[0])) return false 26 | it = a.entries() 27 | while (!(i = it.next()).done) 28 | if (!equal(i.value[1], b.get(i.value[0]))) return false 29 | return true 30 | } 31 | 32 | if (typeof Set === 'function' && a instanceof Set && b instanceof Set) { 33 | if (a.size !== b.size) return false 34 | it = a.entries() 35 | while (!(i = it.next()).done) if (!b.has(i.value[0])) return false 36 | return true 37 | } 38 | 39 | if (a.constructor === RegExp) 40 | return a.source === b.source && a.flags === b.flags 41 | if (a.valueOf !== Object.prototype.valueOf) 42 | return a.valueOf() === b.valueOf() 43 | if (a.toString !== Object.prototype.toString) 44 | return a.toString() === b.toString() 45 | 46 | keys = Object.keys(a) 47 | length = keys.length 48 | if (length !== Object.keys(b).length) return false 49 | 50 | for (i = length; i-- !== 0; ) 51 | if (!Object.prototype.hasOwnProperty.call(b, keys[i])) return false 52 | 53 | if (typeof Element !== 'undefined' && a instanceof Element) return false 54 | 55 | for (i = length; i-- !== 0; ) { 56 | if (keys[i] === '_owner' && a.$$typeof) continue 57 | if (!equal(a[keys[i]], b[keys[i]])) return false 58 | } 59 | return true 60 | } 61 | 62 | // true if both NaN, false otherwise — NaN !== NaN → true 63 | // eslint-disable-next-line no-self-compare 64 | return a !== a && b !== b 65 | } 66 | 67 | export default function isEqual(a: any, b: any) { 68 | try { 69 | return equal(a, b) 70 | } catch (error) { 71 | if ((error.message || '').match(/stack|recursion/i)) { 72 | // eslint-disable-next-line no-console 73 | console.warn('react-fast-compare cannot handle circular refs') 74 | return false 75 | } 76 | throw error 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/utils/resolveOptionsWith.ts: -------------------------------------------------------------------------------- 1 | export type Resolver = (x: any, key: string, obj: object) => any 2 | export type ResolverMap = { [k: string]: Resolver | ResolverMap | boolean } 3 | 4 | export function resolveWith< 5 | T extends { [k: string]: any }, 6 | V extends { [k: string]: any } 7 | >(config: Partial = {}, resolvers: ResolverMap): V { 8 | const result: any = {} 9 | 10 | for (const [key, resolver] of Object.entries(resolvers)) 11 | switch (typeof resolver) { 12 | case 'function': 13 | result[key] = resolver.call(result, config[key], key, config) 14 | break 15 | case 'object': 16 | result[key] = resolveWith(config[key], resolver) 17 | break 18 | case 'boolean': 19 | if (resolver) result[key] = config[key] 20 | break 21 | } 22 | 23 | return result 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/rubberband.ts: -------------------------------------------------------------------------------- 1 | function minMax(value: number, min: number, max: number) { 2 | return Math.max(min, Math.min(value, max)) 3 | } 4 | 5 | // Based on @aholachek ;) 6 | // https://twitter.com/chpwn/status/285540192096497664 7 | // iOS constant = 0.55 8 | 9 | // https://medium.com/@nathangitter/building-fluid-interfaces-ios-swift-9732bb934bf5 10 | function rubberband2(distance: number, constant: number) { 11 | // default constant from the article is 0.7 12 | return Math.pow(distance, constant * 5) 13 | } 14 | 15 | function rubberband(distance: number, dimension: number, constant: number) { 16 | if (dimension === 0 || Math.abs(dimension) === Infinity) 17 | return rubberband2(distance, constant) 18 | return (distance * dimension * constant) / (dimension + constant * distance) 19 | } 20 | 21 | export function rubberbandIfOutOfBounds( 22 | position: number, 23 | min: number, 24 | max: number, 25 | constant = 0.15, 26 | ) { 27 | if (constant === 0) return minMax(position, min, max) 28 | if (position < min) 29 | return -rubberband(min - position, max - min, constant) + min 30 | if (position > max) 31 | return +rubberband(position - max, max - min, constant) + max 32 | return position 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/state.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommonGestureState, 3 | Coordinates, 4 | DistanceAngle, 5 | DragState, 6 | PinchState, 7 | State, 8 | Vector2, 9 | } from '../types' 10 | import { noop } from './utils' 11 | 12 | function getInitial(mixed: T): T & CommonGestureState { 13 | return { 14 | _active: false, 15 | _blocked: false, 16 | _intentional: [false, false], 17 | _movement: [0, 0], 18 | _initial: [0, 0], 19 | _bounds: [ 20 | [-Infinity, Infinity], 21 | [-Infinity, Infinity], 22 | ], 23 | _lastEventType: undefined, 24 | _dragStarted: false, 25 | _dragPreventScroll: false, 26 | _dragIsTap: true, 27 | _dragDelayed: false, 28 | event: undefined, 29 | intentional: false, 30 | values: [0, 0], 31 | velocities: [0, 0], 32 | delta: [0, 0], 33 | movement: [0, 0], 34 | offset: [0, 0], 35 | lastOffset: [0, 0], 36 | direction: [0, 0], 37 | initial: [0, 0], 38 | previous: [0, 0], 39 | first: false, 40 | last: false, 41 | active: false, 42 | timeStamp: 0, 43 | startTime: 0, 44 | elapsedTime: 0, 45 | cancel: noop, 46 | canceled: false, 47 | memo: undefined, 48 | args: undefined, 49 | ...mixed, 50 | } 51 | } 52 | 53 | export function getInitialState(): State { 54 | const shared = { 55 | hovering: false, 56 | scrolling: false, 57 | wheeling: false, 58 | dragging: false, 59 | moving: false, 60 | pinching: false, 61 | touches: 0, 62 | buttons: 0, 63 | down: false, 64 | shiftKey: false, 65 | altKey: false, 66 | metaKey: false, 67 | ctrlKey: false, 68 | locked: false, 69 | } 70 | 71 | const drag = getInitial({ 72 | _pointerId: undefined, 73 | axis: undefined, 74 | xy: [0, 0] as Vector2, 75 | vxvy: [0, 0] as Vector2, 76 | velocity: 0, 77 | distance: 0, 78 | tap: false, 79 | swipe: [0, 0], 80 | }) 81 | 82 | const pinch = getInitial({ 83 | // @ts-expect-error when used _pointerIds we can assert its type will be [number, number] 84 | _pointerIds: [], 85 | da: [0, 0] as Vector2, 86 | vdva: [0, 0] as Vector2, 87 | // @ts-expect-error origin can never be passed as undefined in userland 88 | origin: undefined, 89 | turns: 0, 90 | }) 91 | 92 | const wheel = getInitial({ 93 | axis: undefined, 94 | xy: [0, 0] as Vector2, 95 | vxvy: [0, 0] as Vector2, 96 | velocity: 0, 97 | distance: 0, 98 | }) 99 | 100 | const move = getInitial({ 101 | axis: undefined, 102 | xy: [0, 0] as Vector2, 103 | vxvy: [0, 0] as Vector2, 104 | velocity: 0, 105 | distance: 0, 106 | }) 107 | 108 | const scroll = getInitial({ 109 | axis: undefined, 110 | xy: [0, 0] as Vector2, 111 | vxvy: [0, 0] as Vector2, 112 | velocity: 0, 113 | distance: 0, 114 | }) 115 | 116 | return { shared, drag, pinch, wheel, move, scroll } 117 | } 118 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export function noop() {} 2 | 3 | /** 4 | * TODO Beware that only optimized cases are covered in tests =) 5 | * TODO Need to cover general case as well 6 | * 7 | * @param fns 8 | */ 9 | export function chainFns(...fns: Function[]): Function { 10 | if (fns.length === 0) return noop 11 | if (fns.length === 1) return fns[0] 12 | 13 | return function (this: any) { 14 | var result 15 | for (let fn of fns) { 16 | result = fn.apply(this, arguments) || result 17 | } 18 | return result 19 | } 20 | } 21 | 22 | /** 23 | * Expects a simple value or 2D vector (an array with 2 elements) and 24 | * always returns 2D vector. If simple value is passed, returns a 25 | * vector with this value as both coordinates. 26 | * 27 | * @param value 28 | */ 29 | export function ensureVector( 30 | value: T | [T, T] | undefined, 31 | fallback?: T | [T, T], 32 | ): [T, T] { 33 | if (value === undefined) { 34 | if (fallback === undefined) { 35 | throw new Error('Must define fallback value if undefined is expected') 36 | } 37 | value = fallback 38 | } 39 | 40 | if (Array.isArray(value)) return value 41 | return [value, value] 42 | } 43 | 44 | /** 45 | * Helper for defining a default value 46 | * 47 | * @param value 48 | * @param fallback 49 | */ 50 | export function assignDefault( 51 | value: Partial | undefined, 52 | fallback: T, 53 | ): T { 54 | return Object.assign({}, fallback, value || {}) 55 | } 56 | 57 | /** 58 | * Resolves getters (functions) by calling them 59 | * If simple value is given it just passes through 60 | * 61 | * @param v 62 | */ 63 | export function valueFn(v: T | ((...args: any[]) => T), ...args: any[]): T { 64 | if (typeof v === 'function') { 65 | // @ts-ignore 66 | return v(...args) 67 | } else { 68 | return v 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite-plugin-windicss' 2 | import colors from 'windicss/colors' 3 | 4 | defineConfig({ 5 | darkMode: false, // or 'media' or 'class' 6 | theme: { 7 | extend: { 8 | colors: { 9 | ...colors, 10 | transparent: 'transparent', 11 | current: 'currentColor', 12 | }, 13 | }, 14 | }, 15 | }) 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src/types/global.d.ts", 4 | "src/**/*.ts", 5 | "tests/**/*.ts", 6 | "demo/**/*.ts", 7 | "demo/**/*.d.ts", 8 | "demo/**/*.tsx", 9 | "demo/**/*.vue" 10 | ], 11 | "exclude": ["dist", "node_modules"], 12 | "compilerOptions": { 13 | "baseUrl": ".", 14 | "rootDir": ".", 15 | "outDir": "dist", 16 | "sourceMap": false, 17 | "noEmit": true, 18 | 19 | "target": "esnext", 20 | "module": "esnext", 21 | "moduleResolution": "node", 22 | "skipLibCheck": true, 23 | 24 | "noUnusedLocals": true, 25 | "strictNullChecks": true, 26 | "noImplicitAny": true, 27 | "noImplicitThis": true, 28 | "noImplicitReturns": true, 29 | "strict": true, 30 | "isolatedModules": false, 31 | 32 | "experimentalDecorators": true, 33 | "resolveJsonModule": true, 34 | "esModuleInterop": true, 35 | "removeComments": false, 36 | "jsx": "preserve", 37 | "lib": ["esnext", "dom"], 38 | "types": ["node", "jest"], 39 | "plugins": [ 40 | { 41 | "name": "@vuedx/typescript-plugin-vue" 42 | } 43 | ], 44 | "paths": { 45 | "@vueuse/gesture": ["./src/index.ts"], 46 | "@vueuse/gesture/*": ["./src/*"] 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import vue from '@vitejs/plugin-vue' 2 | import { resolve } from 'path' 3 | import { defineConfig } from 'vite' 4 | import WindiCSS from 'vite-plugin-windicss' 5 | 6 | export default defineConfig({ 7 | root: 'demo/', 8 | plugins: [vue(), WindiCSS()], 9 | resolve: { 10 | alias: [ 11 | { 12 | find: '@vueuse/gesture', 13 | replacement: resolve(__dirname, './src/index.ts'), 14 | }, 15 | ], 16 | }, 17 | }) 18 | --------------------------------------------------------------------------------