Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
2 changes: 2 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export default defineConfig<ThemeConfig>({
{ 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' },
Expand All @@ -78,6 +79,7 @@ export default defineConfig<ThemeConfig>({
{ 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' },
],
},
Expand Down
15 changes: 15 additions & 0 deletions docs/en/events/swipe.md
Original file line number Diff line number Diff line change
@@ -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**).
1 change: 1 addition & 0 deletions docs/en/modules/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
28 changes: 28 additions & 0 deletions docs/en/modules/swipe.md
Original file line number Diff line number Diff line change
@@ -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**.
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
81 changes: 81 additions & 0 deletions src/modules/Swipe.ts
Original file line number Diff line number Diff line change
@@ -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<Options> {
#gesturePointers: Array<Pointer['records']> = [];

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,
});
};
}
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
141 changes: 141 additions & 0 deletions tests/swipe.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
18 changes: 16 additions & 2 deletions tests/testUtils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -33,7 +40,7 @@ beforeEach(() => {
);
});

type ModulePreset = [WheelPanZoom, Drag, Click];
type ModulePreset = [WheelPanZoom, Drag, Click, Swipe];

class Accumulator {
pan = {
Expand All @@ -46,18 +53,23 @@ class Accumulator {
};
scale = 1;
clicks = 0;
swipes: StdEvents['swipe'][] = [];
private pointeract: PointeractInterface<ModulePreset>;
constructor(pointeract: PointeractInterface<ModulePreset>) {
this.pointeract = pointeract;
pointeract.on('pan', this.panner);
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;
Expand All @@ -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);
};
}

Expand Down
Loading