diff --git a/README.md b/README.md index c32a62e..94794eb 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ Congratulations! You can now press your mouse or finger to the element and move, - **Click (Double Click, Triple Click, Quadruple Click, Any Click)** - **Drag** +- **Swipe (All directions)** - **Pan and Zoom via Mouse Wheel (`ctrl`/`shift` key binding, touchpad support)** - **Pan and Zoom via Multitouch (Pan, Pinch)** - **One-line Prevent Default** diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 0be0aba..bf5ea91 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -66,6 +66,7 @@ export default defineConfig({ { text: 'Prevent Default', link: '/modules/prevent-default' }, { text: 'Click', link: '/modules/click' }, { text: 'Drag', link: '/modules/drag' }, + { text: 'Swipe', link: '/modules/swipe' }, { text: 'Wheel Pan Zoom', link: '/modules/wheel-pan-zoom' }, { text: 'Multitouch Pan Zoom', link: '/modules/multitouch-pan-zoom' }, { text: 'Lubricator', link: '/modules/lubricator' }, @@ -78,6 +79,7 @@ export default defineConfig({ { text: 'Pan', link: '/events/pan' }, { text: 'True Click', link: '/events/true-click' }, { text: 'Drag', link: '/events/drag' }, + { text: 'Swipe', link: '/events/swipe' }, { text: 'Zoom', link: '/events/zoom' }, ], }, diff --git a/docs/en/events/swipe.md b/docs/en/events/swipe.md new file mode 100644 index 0000000..0f4c622 --- /dev/null +++ b/docs/en/events/swipe.md @@ -0,0 +1,15 @@ +# Swipe Event + +- **Event Name**: `swipe` +- **Access Type**: `Events['swipe']` +- **Details**: + +```TypeScript +type SwipeEvent = { + direction: 'left' | 'right' | 'up' | 'down'; + velocity: number; +} +``` + +- `direction`: the direction of the swipe. +- `velocity`: the velocity of the swipe, calculated as the distance of the swipe divided by the time it took to complete the swipe (**px/ms**). diff --git a/docs/en/modules/index.md b/docs/en/modules/index.md index db7309f..4f6c824 100644 --- a/docs/en/modules/index.md +++ b/docs/en/modules/index.md @@ -7,6 +7,7 @@ Here you can find all modules shipped with Pointeract: | [`PreventDefault`](/modules/prevent-default) | The Element | Prevents default browser behavior. | | [`Click`](/modules/click) | Single Pointer | Checks if a click was performed without any movement. | | [`Drag`](/modules/drag) | Single Pointer | Tracks pointer movement and emits drag events. | +| [`Swipe`](/modules/swipe) | Multitouch | Detects swipe gestures and emits swipe events. | | [`WheelPanZoom`](/modules/wheel-pan-zoom) | Mouse Wheel | Tracks pointer wheel movement and key press. | | [`MultiTouchPanZoom`](/modules/multitouch-pan-zoom) | Multitouch | Resolves pan/zoom by analyzing movement of two touches. | | [`Lubricator`](/modules/lubricator) | Events | Smoothify configured events by intercepting them and re-emit interpolated ones. | diff --git a/docs/en/modules/swipe.md b/docs/en/modules/swipe.md new file mode 100644 index 0000000..235cea0 --- /dev/null +++ b/docs/en/modules/swipe.md @@ -0,0 +1,28 @@ +# Swipe Module + +This module handles swipe interactions, events are dispatched when a single (or multiple) touch or mouse is pressed and moved in a specific direction. + +**Event**: [`swipe`](/events/swipe) + +## Loading + +```TypeScript +import { Swipe, Pointeract } from 'pointeract'; +const pointeract = new Pointeract({ element: app }, [Swipe]); +``` + +## Options + +```TypeScript +interface Options extends BaseOptions { + minDistance?: number; + minVelocity?: number; + velocityWindow?: number; + pointers?: number; +} +``` + +- `minDistance`: The minimum distance in pixels that the pointer must move to be considered a swipe. Defaults to **20**. +- `minVelocity`: The minimum velocity in pixels per second that the pointer must move to be considered a swipe. Defaults to **0**. +- `velocityWindow`: The time window in milliseconds used to calculate the velocity of the swipe. Defaults to **100**. +- `pointers`: The number of pointers that must be active for the swipe event to be dispatched. Defaults to **1**. diff --git a/src/index.ts b/src/index.ts index 21fde8f..9fdb86f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ export { default as BaseModule, type BaseArgs, type Options, type Events } from export type { Pointer, Pointers, StdEvents, BaseOptions } from '@/types'; export { default as Click } from '@/modules/Click'; export { default as Drag } from '@/modules/Drag'; +export { default as Swipe } from '@/modules/Swipe'; export { default as MultitouchPanZoom } from '@/modules/MultitouchPanZoom'; export { default as PreventDefault } from '@/modules/PreventDefault'; export { default as WheelPanZoom } from '@/modules/WheelPanZoom'; diff --git a/src/modules/Swipe.ts b/src/modules/Swipe.ts new file mode 100644 index 0000000..d44b55f --- /dev/null +++ b/src/modules/Swipe.ts @@ -0,0 +1,81 @@ +import type { BaseOptions, Pointer, Pointers } from '@/types'; +import BaseModule from '@/BaseModule'; +import { getLast } from '@/utils'; + +interface Options extends BaseOptions { + minDistance?: number; + minVelocity?: number; + velocityWindow?: number; + pointers?: number; +} + +type Direction = 'left' | 'right' | 'up' | 'down'; + +export default class Swipe extends BaseModule { + #gesturePointers: Array = []; + + onPointerDown = (_e: PointerEvent, _pointer: Pointer, pointers: Pointers) => { + if (pointers.size === 1) this.#gesturePointers = []; + }; + + #processPointer( + records: Pointer['records'], + minDistance: number, + minVelocity: number, + velocityWindow: number, + ): { direction: Direction; velocity: number } | null { + if (records.length < 2) return null; + + const first = records[0]; + const last = getLast(records); + const dx = last.x - first.x; + const dy = last.y - first.y; + if (Math.sqrt(dx * dx + dy * dy) < minDistance) return null; + + const direction: Direction = + Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : dy > 0 ? 'down' : 'up'; + + const windowRecords = records.filter((r) => r.timestamp >= last.timestamp - velocityWindow); + let velocity = 0; + if (windowRecords.length >= 2) { + const wFirst = windowRecords[0]; + const wLast = getLast(windowRecords); + const wDx = wLast.x - wFirst.x; + const wDy = wLast.y - wFirst.y; + const wTime = wLast.timestamp - wFirst.timestamp; + velocity = wTime > 0 ? Math.sqrt(wDx * wDx + wDy * wDy) / wTime : 0; + } + if (velocity < minVelocity) return null; + + return { direction, velocity }; + } + + onPointerUp = (_e: PointerEvent, pointer: Pointer, pointers: Pointers) => { + this.#gesturePointers.push(pointer.records); + + if (pointers.size > 0) return; + if (this.#gesturePointers.length < (this.options.pointers ?? 1)) return; + + const minDistance = this.options.minDistance ?? 10; + const minVelocity = this.options.minVelocity ?? 0.1; + const velocityWindow = this.options.velocityWindow ?? 100; + + let direction: Direction | null = null; + let totalVelocity = 0; + + for (const records of this.#gesturePointers) { + const result = this.#processPointer(records, minDistance, minVelocity, velocityWindow); + if (!result) return; + if (direction === null) direction = result.direction; + else if (direction !== result.direction) return; + totalVelocity += result.velocity; + } + + if (!direction) return; + + this.dispatch('swipe', { + direction, + velocity: totalVelocity / this.#gesturePointers.length, + }); + }; +} diff --git a/src/types.ts b/src/types.ts index 9f4a603..e0c8249 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,6 +46,7 @@ export type Pointer = { export interface StdEvents { pan: { deltaX: number; deltaY: number }; drag: { deltaX: number; deltaY: number; x: number; y: number }; + swipe: { direction: 'left' | 'right' | 'up' | 'down'; velocity: number }; trueClick: Coordinates & { target: EventTarget | null; streak: number }; zoom: Coordinates & { factor: number }; } diff --git a/tests/swipe.test.ts b/tests/swipe.test.ts new file mode 100644 index 0000000..a32617e --- /dev/null +++ b/tests/swipe.test.ts @@ -0,0 +1,141 @@ +import { Swipe } from '@'; +import { expect, test, vi } from 'vitest'; +import setup from './testUtils'; + +test('swipe right', async () => { + const { acc, dispose, Pointer } = setup([Swipe], { minVelocity: 0 }); + const p = new Pointer(); + p.down(); + p.move({ x: 50, y: 0 }); + p.up(); + expect(acc.swipes).toHaveLength(1); + expect(acc.swipes[0].direction).toBe('right'); + await dispose(); +}); + +test('swipe left', async () => { + const { acc, dispose, Pointer } = setup([Swipe], { minVelocity: 0 }); + const p = new Pointer(); + p.down({ x: 100, y: 0 }); + p.move({ x: -50, y: 0 }); + p.up(); + expect(acc.swipes).toHaveLength(1); + expect(acc.swipes[0].direction).toBe('left'); + await dispose(); +}); + +test('swipe up', async () => { + const { acc, dispose, Pointer } = setup([Swipe], { minVelocity: 0 }); + const p = new Pointer(); + p.down({ x: 0, y: 100 }); + p.move({ x: 0, y: -50 }); + p.up(); + expect(acc.swipes).toHaveLength(1); + expect(acc.swipes[0].direction).toBe('up'); + await dispose(); +}); + +test('swipe down', async () => { + const { acc, dispose, Pointer } = setup([Swipe], { minVelocity: 0 }); + const p = new Pointer(); + p.down(); + p.move({ x: 0, y: 50 }); + p.up(); + expect(acc.swipes).toHaveLength(1); + expect(acc.swipes[0].direction).toBe('down'); + await dispose(); +}); + +test('does not fire when distance is below threshold', async () => { + const { acc, dispose, Pointer } = setup([Swipe], { minVelocity: 0, minDistance: 20 }); + const p = new Pointer(); + p.down(); + p.move({ x: 10, y: 0 }); + p.up(); + expect(acc.swipes).toHaveLength(0); + await dispose(); +}); + +test('does not fire when velocity is below threshold', async () => { + let time = 0; + const dateSpy = vi.spyOn(Date, 'now').mockImplementation(() => time); + const { acc, dispose, Pointer } = setup([Swipe], { minVelocity: 1 }); + const p = new Pointer(); + p.down(); + // no time advances → all timestamps at 0 → wTime = 0 → velocity = 0 + p.move({ x: 50, y: 0 }); + p.up(); + expect(acc.swipes).toHaveLength(0); + dateSpy.mockRestore(); + await dispose(); +}); + +test('velocity is computed correctly', async () => { + let time = 0; + const dateSpy = vi.spyOn(Date, 'now').mockImplementation(() => time); + const { acc, dispose, Pointer } = setup([Swipe]); + const p = new Pointer(); + p.down(); // t=0, x=0 + time = 50; + p.move({ x: 50, y: 0 }); // t=50, x=50 + time = 100; + p.move({ x: 50, y: 0 }); // t=100, x=100 + p.up(); + expect(acc.swipes).toHaveLength(1); + expect(acc.swipes[0].velocity).toBeCloseTo(1, 1); // 100px / 100ms = 1 px/ms + dateSpy.mockRestore(); + await dispose(); +}); + +test('two-finger swipe fires', async () => { + const { acc, dispose, Pointer } = setup([Swipe], { minVelocity: 0 }); + const p1 = new Pointer(); + const p2 = new Pointer(); + p1.down({ x: 0, y: 50 }); + p2.down({ x: 50, y: 50 }); + p1.move({ x: 50, y: 0 }); + p2.move({ x: 50, y: 0 }); + p1.up(); + p2.up(); + expect(acc.swipes).toHaveLength(1); + expect(acc.swipes[0].direction).toBe('right'); + await dispose(); +}); + +test('two-finger swipe in opposite directions does not fire', async () => { + const { acc, dispose, Pointer } = setup([Swipe], { minVelocity: 0 }); + const p1 = new Pointer(); + const p2 = new Pointer(); + p1.down({ x: 50, y: 50 }); + p2.down({ x: 100, y: 50 }); + p1.move({ x: 50, y: 0 }); + p2.move({ x: -50, y: 0 }); + p1.up(); + p2.up(); + expect(acc.swipes).toHaveLength(0); + await dispose(); +}); + +test('pointers option: single finger does not fire when 2 required', async () => { + const { acc, dispose, Pointer } = setup([Swipe], { minVelocity: 0, pointers: 2 }); + const p = new Pointer(); + p.down(); + p.move({ x: 50, y: 0 }); + p.up(); + expect(acc.swipes).toHaveLength(0); + await dispose(); +}); + +test('pointers option: two fingers fire when 2 required', async () => { + const { acc, dispose, Pointer } = setup([Swipe], { minVelocity: 0, pointers: 2 }); + const p1 = new Pointer(); + const p2 = new Pointer(); + p1.down({ x: 0, y: 50 }); + p2.down({ x: 50, y: 50 }); + p1.move({ x: 50, y: 0 }); + p2.move({ x: 50, y: 0 }); + p1.up(); + p2.up(); + expect(acc.swipes).toHaveLength(1); + await dispose(); +}); diff --git a/tests/testUtils.ts b/tests/testUtils.ts index 1299109..cefd49b 100644 --- a/tests/testUtils.ts +++ b/tests/testUtils.ts @@ -1,4 +1,11 @@ -import { type Click, type Drag, PointeractInterface, type WheelPanZoom, Pointeract } from '@'; +import { + type Click, + type Drag, + PointeractInterface, + type WheelPanZoom, + type Swipe, + Pointeract, +} from '@'; import { Window as HappyWindow, PointerEvent, HTMLDivElement, WheelEvent } from 'happy-dom'; import { beforeEach, vi } from 'vitest'; import type { Coordinates, StdEvents } from '@/types'; @@ -33,7 +40,7 @@ beforeEach(() => { ); }); -type ModulePreset = [WheelPanZoom, Drag, Click]; +type ModulePreset = [WheelPanZoom, Drag, Click, Swipe]; class Accumulator { pan = { @@ -46,6 +53,7 @@ class Accumulator { }; scale = 1; clicks = 0; + swipes: StdEvents['swipe'][] = []; private pointeract: PointeractInterface; constructor(pointeract: PointeractInterface) { this.pointeract = pointeract; @@ -53,11 +61,15 @@ class Accumulator { pointeract.on('drag', this.dragger); pointeract.on('zoom', this.zoomer); pointeract.on('trueClick', this.clicker); + pointeract.on('swipe', this.swiper); } private panner = (e: StdEvents['pan']) => { this.pan.x += e.deltaX; this.pan.y += e.deltaY; }; + private swiper = (e: StdEvents['swipe']) => { + this.swipes.push(e); + }; private dragger = (e: StdEvents['drag']) => { this.drag.x += e.deltaX; this.drag.y += e.deltaY; @@ -75,12 +87,14 @@ class Accumulator { }; this.scale = 1; this.clicks = 0; + this.swipes = []; }; dispose = () => { this.pointeract.off('pan', this.panner); this.pointeract.off('drag', this.dragger); this.pointeract.off('zoom', this.zoomer); this.pointeract.off('trueClick', this.clicker); + this.pointeract.off('swipe', this.swiper); }; }