mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 05:37:21 +00:00
feat(core): add gesture system (#1287)
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import { listen } from '@videojs/utils/dom';
|
||||
|
||||
import type { GestureBinding, GestureMatchResult, GestureRecognizer, GestureRegion, GestureType } from './gesture';
|
||||
import { resolveRegion } from './region';
|
||||
|
||||
const TAP_THRESHOLD = 250;
|
||||
|
||||
export class GestureCoordinator {
|
||||
#target: HTMLElement;
|
||||
#bindings: GestureBinding[] = [];
|
||||
#recognizers = new Set<GestureRecognizer>();
|
||||
#disconnect: AbortController | null = null;
|
||||
|
||||
constructor(target: HTMLElement) {
|
||||
this.#target = target;
|
||||
}
|
||||
|
||||
add(binding: GestureBinding): () => void {
|
||||
this.#bindings.push(binding);
|
||||
this.#recognizers.add(binding.recognizer);
|
||||
this.#connect();
|
||||
|
||||
let removed = false;
|
||||
return () => {
|
||||
if (removed) return;
|
||||
removed = true;
|
||||
|
||||
const idx = this.#bindings.indexOf(binding);
|
||||
if (idx !== -1) this.#bindings.splice(idx, 1);
|
||||
|
||||
this.#maybeDisconnect();
|
||||
};
|
||||
}
|
||||
|
||||
// --- Private ---
|
||||
|
||||
#connect(): void {
|
||||
if (this.#disconnect) return;
|
||||
this.#disconnect = new AbortController();
|
||||
const { signal } = this.#disconnect;
|
||||
|
||||
let pointerDownTime = 0;
|
||||
|
||||
listen(
|
||||
this.#target,
|
||||
'pointerdown',
|
||||
() => {
|
||||
pointerDownTime = Date.now();
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
|
||||
listen(
|
||||
this.#target,
|
||||
'pointerup',
|
||||
(event) => {
|
||||
if (Date.now() - pointerDownTime > TAP_THRESHOLD) return;
|
||||
|
||||
const pointerType = event.pointerType;
|
||||
const clientX = event.clientX;
|
||||
const target = this.#target;
|
||||
const bindings = this.#bindings;
|
||||
|
||||
const matches: GestureMatchResult = {
|
||||
resolve: (type) => matchBindings(bindings, type, pointerType, clientX, target),
|
||||
};
|
||||
|
||||
for (const recognizer of this.#recognizers) {
|
||||
recognizer.handleUp(matches, event);
|
||||
}
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
#maybeDisconnect(): void {
|
||||
if (this.#bindings.length > 0) return;
|
||||
|
||||
for (const recognizer of this.#recognizers) {
|
||||
recognizer.reset();
|
||||
}
|
||||
|
||||
this.#recognizers.clear();
|
||||
this.#disconnect?.abort();
|
||||
this.#disconnect = null;
|
||||
}
|
||||
}
|
||||
|
||||
const coordinators = new WeakMap<HTMLElement, GestureCoordinator>();
|
||||
|
||||
/** Look up the gesture coordinator for a target element, if one exists. */
|
||||
export function findGestureCoordinator(target: HTMLElement): GestureCoordinator | undefined {
|
||||
return coordinators.get(target);
|
||||
}
|
||||
|
||||
export function getGestureCoordinator(target: HTMLElement): GestureCoordinator {
|
||||
let coordinator = coordinators.get(target);
|
||||
if (!coordinator) {
|
||||
coordinator = new GestureCoordinator(target);
|
||||
coordinators.set(target, coordinator);
|
||||
}
|
||||
return coordinator;
|
||||
}
|
||||
|
||||
// --- Matching ---
|
||||
|
||||
function matchBindings(
|
||||
bindings: GestureBinding[],
|
||||
type: GestureType,
|
||||
pointerType: string,
|
||||
clientX: number,
|
||||
target: HTMLElement
|
||||
): GestureBinding[] {
|
||||
const rect = target.getBoundingClientRect();
|
||||
const activeRegions = getActiveRegions(bindings, type, pointerType);
|
||||
const region = activeRegions.size > 0 ? resolveRegion(clientX, rect, activeRegions) : null;
|
||||
|
||||
const matches: GestureBinding[] = [];
|
||||
|
||||
for (const binding of bindings) {
|
||||
if (binding.disabled) continue;
|
||||
if (binding.type !== type) continue;
|
||||
if (binding.pointer && binding.pointer !== pointerType) continue;
|
||||
|
||||
if (binding.region) {
|
||||
if (binding.region !== region) continue;
|
||||
} else if (region !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
matches.push(binding);
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function getActiveRegions(bindings: GestureBinding[], type: GestureType, pointerType: string): Set<GestureRegion> {
|
||||
const regions = new Set<GestureRegion>();
|
||||
for (const binding of bindings) {
|
||||
if (binding.disabled) continue;
|
||||
if (binding.type !== type) continue;
|
||||
if (binding.pointer && binding.pointer !== pointerType) continue;
|
||||
if (binding.region) regions.add(binding.region);
|
||||
}
|
||||
return regions;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { getGestureCoordinator } from './coordinator';
|
||||
import type { GestureOptions } from './gesture';
|
||||
import { TapRecognizer } from './tap';
|
||||
|
||||
const recognizers = new WeakMap<HTMLElement, TapRecognizer>();
|
||||
|
||||
function getRecognizer(target: HTMLElement): TapRecognizer {
|
||||
let recognizer = recognizers.get(target);
|
||||
if (recognizer) return recognizer;
|
||||
|
||||
recognizer = new TapRecognizer();
|
||||
recognizers.set(target, recognizer);
|
||||
return recognizer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a tap gesture on a target element.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const cleanup = createTapGesture(container, (event) => {
|
||||
* store.paused ? store.play() : store.pause();
|
||||
* }, { pointer: 'mouse' });
|
||||
* ```
|
||||
*/
|
||||
export function createTapGesture(
|
||||
target: HTMLElement,
|
||||
onActivate: (event: PointerEvent) => void,
|
||||
options?: GestureOptions
|
||||
): () => void {
|
||||
return getGestureCoordinator(target).add({
|
||||
type: 'tap',
|
||||
recognizer: getRecognizer(target),
|
||||
onActivate,
|
||||
pointer: options?.pointer,
|
||||
region: options?.region,
|
||||
disabled: options?.disabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a doubletap gesture on a target element.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const cleanup = createDoubleTapGesture(container, (event) => {
|
||||
* store.fullscreen ? store.exitFullscreen() : store.requestFullscreen();
|
||||
* }, { region: 'center' });
|
||||
* ```
|
||||
*/
|
||||
export function createDoubleTapGesture(
|
||||
target: HTMLElement,
|
||||
onActivate: (event: PointerEvent) => void,
|
||||
options?: GestureOptions
|
||||
): () => void {
|
||||
return getGestureCoordinator(target).add({
|
||||
type: 'doubletap',
|
||||
recognizer: getRecognizer(target),
|
||||
onActivate,
|
||||
pointer: options?.pointer,
|
||||
region: options?.region,
|
||||
disabled: options?.disabled,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export type GesturePointerType = 'mouse' | 'touch' | 'pen';
|
||||
|
||||
export type GestureType = 'tap' | 'doubletap';
|
||||
|
||||
export type GestureRegion = 'left' | 'center' | 'right';
|
||||
|
||||
export interface GestureOptions {
|
||||
pointer?: GesturePointerType | undefined;
|
||||
region?: GestureRegion | undefined;
|
||||
disabled?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface GestureBinding {
|
||||
type: GestureType;
|
||||
recognizer: GestureRecognizer;
|
||||
onActivate: (event: PointerEvent) => void;
|
||||
pointer?: GesturePointerType | undefined;
|
||||
region?: GestureRegion | undefined;
|
||||
disabled?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface GestureRecognizer {
|
||||
/** Handle a confirmed quick pointer-up and decide when to fire matched bindings. */
|
||||
handleUp(matches: GestureMatchResult, event: PointerEvent): void;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export interface GestureMatchResult {
|
||||
/** Resolve current matches for a gesture type (reads fresh rect, re-filters bindings). */
|
||||
resolve(type: GestureType): GestureBinding[];
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { GestureRegion } from './gesture';
|
||||
|
||||
/**
|
||||
* Determine which named region a pointer position falls into.
|
||||
*
|
||||
* Regions divide the container width equally based on how many are active:
|
||||
* - `left` + `right` → halves (50% / 50%)
|
||||
* - `left` + `center` + `right` → thirds (33% / 34% / 33%)
|
||||
*
|
||||
* Single region: `left` covers the left half, `right` the right half,
|
||||
* and `center` covers the full surface. Partial two-region combos
|
||||
* (e.g. `left` + `center`) use the same natural zones — positions outside
|
||||
* all active zones return `null` so full-surface gestures can handle them.
|
||||
*/
|
||||
export function resolveRegion(
|
||||
clientX: number,
|
||||
containerRect: DOMRect,
|
||||
activeRegions: ReadonlySet<GestureRegion>
|
||||
): GestureRegion | null {
|
||||
if (activeRegions.size === 0) return null;
|
||||
|
||||
const relativeX = clientX - containerRect.left;
|
||||
const width = containerRect.width;
|
||||
|
||||
if (width === 0) return null;
|
||||
|
||||
const ratio = relativeX / width;
|
||||
|
||||
if (activeRegions.size === 2 && activeRegions.has('left') && activeRegions.has('right')) {
|
||||
return ratio < 0.5 ? 'left' : 'right';
|
||||
}
|
||||
|
||||
if (activeRegions.size === 3) {
|
||||
if (ratio < 1 / 3) return 'left';
|
||||
if (ratio < 2 / 3) return 'center';
|
||||
return 'right';
|
||||
}
|
||||
|
||||
// Single region or partial combinations — each region covers its natural zone.
|
||||
// Pointer outside all active zones returns null (full-surface gesture handles it).
|
||||
// Note: with `left` + `center`, left covers 0–50% and center covers 33–66%.
|
||||
// Overlap at 33–50% resolves to `left` (checked first).
|
||||
if (activeRegions.has('left') && ratio < 0.5) return 'left';
|
||||
if (activeRegions.has('right') && ratio >= 0.5) return 'right';
|
||||
if (activeRegions.has('center')) {
|
||||
if (activeRegions.size === 1) return 'center';
|
||||
if (ratio >= 1 / 3 && ratio < 2 / 3) return 'center';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { GestureMatchResult, GestureRecognizer } from './gesture';
|
||||
|
||||
const DOUBLETAP_WINDOW = 300;
|
||||
|
||||
/**
|
||||
* Recognizes tap vs doubletap from quick pointer-up events.
|
||||
*
|
||||
* Stateful recognizer — tracks tap count and doubletap timing.
|
||||
* The coordinator handles pointer-down timing (tap threshold) and
|
||||
* calls `handleUp()` only for quick taps that passed the threshold check.
|
||||
*/
|
||||
export class TapRecognizer implements GestureRecognizer {
|
||||
#lastTapTime = 0;
|
||||
#tapTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
handleUp(matches: GestureMatchResult, event: PointerEvent): void {
|
||||
const hasDoubletap = matches.resolve('doubletap').length > 0;
|
||||
|
||||
if (hasDoubletap) {
|
||||
const now = Date.now();
|
||||
|
||||
if (now - this.#lastTapTime < DOUBLETAP_WINDOW) {
|
||||
// Second tap within window — doubletap.
|
||||
this.#clearTimer();
|
||||
this.#lastTapTime = 0;
|
||||
// Re-resolve at fire time so removed bindings between taps are respected.
|
||||
matches.resolve('doubletap')[0]?.onActivate(event);
|
||||
return;
|
||||
}
|
||||
|
||||
// First tap — defer to allow doubletap window.
|
||||
this.#lastTapTime = now;
|
||||
this.#clearTimer();
|
||||
this.#tapTimer = setTimeout(() => {
|
||||
this.#tapTimer = null;
|
||||
this.#lastTapTime = 0;
|
||||
// Re-resolve at fire time so cleanup between pointerup and timeout is respected.
|
||||
matches.resolve('tap')[0]?.onActivate(event);
|
||||
}, DOUBLETAP_WINDOW);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// No doubletap bindings — fire tap immediately.
|
||||
matches.resolve('tap')[0]?.onActivate(event);
|
||||
}
|
||||
|
||||
#clearTimer(): void {
|
||||
if (this.#tapTimer !== null) {
|
||||
clearTimeout(this.#tapTimer);
|
||||
this.#tapTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.#clearTimer();
|
||||
this.#lastTapTime = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createDoubleTapGesture, createTapGesture } from '../create-tap-gesture';
|
||||
|
||||
const DOUBLETAP_WINDOW = 300;
|
||||
|
||||
function setup() {
|
||||
const container = document.createElement('div');
|
||||
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 0,
|
||||
right: 300,
|
||||
width: 300,
|
||||
top: 0,
|
||||
bottom: 200,
|
||||
height: 200,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => {},
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('createTapGesture', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('fires on quick pointer down + up', () => {
|
||||
const container = setup();
|
||||
const handler = vi.fn();
|
||||
createTapGesture(container, handler);
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not fire on long press', () => {
|
||||
const container = setup();
|
||||
const handler = vi.fn();
|
||||
createTapGesture(container, handler);
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(300);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fire when disabled', () => {
|
||||
const container = setup();
|
||||
const handler = vi.fn();
|
||||
createTapGesture(container, handler, { disabled: true });
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fires immediately when no doubletap bindings exist', () => {
|
||||
const container = setup();
|
||||
const handler = vi.fn();
|
||||
createTapGesture(container, handler);
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('cleanup removes binding', () => {
|
||||
const container = setup();
|
||||
const handler = vi.fn();
|
||||
const cleanup = createTapGesture(container, handler);
|
||||
|
||||
cleanup();
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('double cleanup is safe', () => {
|
||||
const container = setup();
|
||||
const cleanup = createTapGesture(container, vi.fn());
|
||||
cleanup();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('first registered binding wins when multiple match', () => {
|
||||
const container = setup();
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
|
||||
createTapGesture(container, first);
|
||||
createTapGesture(container, second);
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
expect(first).toHaveBeenCalledOnce();
|
||||
expect(second).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-registers after all bindings cleaned up', () => {
|
||||
const container = setup();
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
|
||||
// Register and immediately clean up.
|
||||
const cleanup = createTapGesture(container, first);
|
||||
cleanup();
|
||||
|
||||
// Re-register on the same element — should re-attach pointer listeners.
|
||||
createTapGesture(container, second);
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
expect(first).not.toHaveBeenCalled();
|
||||
expect(second).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDoubleTapGesture', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('fires on two quick taps', () => {
|
||||
const container = setup();
|
||||
const handler = vi.fn();
|
||||
createDoubleTapGesture(container, handler);
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not fire on single tap', () => {
|
||||
const container = setup();
|
||||
const handler = vi.fn();
|
||||
createDoubleTapGesture(container, handler);
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
vi.advanceTimersByTime(DOUBLETAP_WINDOW + 50);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fire when taps are too far apart', () => {
|
||||
const container = setup();
|
||||
const handler = vi.fn();
|
||||
createDoubleTapGesture(container, handler);
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
vi.advanceTimersByTime(DOUBLETAP_WINDOW + 50);
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fire when disabled', () => {
|
||||
const container = setup();
|
||||
const handler = vi.fn();
|
||||
createDoubleTapGesture(container, handler, { disabled: true });
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tap/doubletap disambiguation', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('delays tap when doubletap bindings exist', () => {
|
||||
const container = setup();
|
||||
const tapHandler = vi.fn();
|
||||
const doubletapHandler = vi.fn();
|
||||
|
||||
createTapGesture(container, tapHandler);
|
||||
createDoubleTapGesture(container, doubletapHandler);
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
expect(tapHandler).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(DOUBLETAP_WINDOW);
|
||||
expect(tapHandler).toHaveBeenCalledOnce();
|
||||
expect(doubletapHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels pending tap on doubletap', () => {
|
||||
const container = setup();
|
||||
const tapHandler = vi.fn();
|
||||
const doubletapHandler = vi.fn();
|
||||
|
||||
createTapGesture(container, tapHandler);
|
||||
createDoubleTapGesture(container, doubletapHandler);
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
expect(doubletapHandler).toHaveBeenCalledOnce();
|
||||
expect(tapHandler).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(DOUBLETAP_WINDOW);
|
||||
expect(tapHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deferred tap does not fire after binding cleanup', () => {
|
||||
const container = setup();
|
||||
const tapHandler = vi.fn();
|
||||
const doubletapHandler = vi.fn();
|
||||
|
||||
const tapCleanup = createTapGesture(container, tapHandler);
|
||||
createDoubleTapGesture(container, doubletapHandler);
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
tapCleanup();
|
||||
|
||||
vi.advanceTimersByTime(DOUBLETAP_WINDOW);
|
||||
expect(tapHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pointer filtering', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('only fires for matching pointer type', () => {
|
||||
const container = setup();
|
||||
const touchHandler = vi.fn();
|
||||
const mouseHandler = vi.fn();
|
||||
|
||||
createTapGesture(container, touchHandler, { pointer: 'touch' });
|
||||
createTapGesture(container, mouseHandler, { pointer: 'mouse' });
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'touch', clientX: 150 });
|
||||
|
||||
expect(touchHandler).toHaveBeenCalledOnce();
|
||||
expect(mouseHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fires for all pointer types when no filter set', () => {
|
||||
const container = setup();
|
||||
const handler = vi.fn();
|
||||
createTapGesture(container, handler);
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'touch', clientX: 150 });
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('regions', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('fires region binding in its zone', () => {
|
||||
const container = setup();
|
||||
const leftHandler = vi.fn();
|
||||
const rightHandler = vi.fn();
|
||||
|
||||
createTapGesture(container, leftHandler, { region: 'left' });
|
||||
createTapGesture(container, rightHandler, { region: 'right' });
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 50 });
|
||||
|
||||
expect(leftHandler).toHaveBeenCalledOnce();
|
||||
expect(rightHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('region binding takes priority over full-surface', () => {
|
||||
const container = setup();
|
||||
const regionHandler = vi.fn();
|
||||
const fullHandler = vi.fn();
|
||||
|
||||
createTapGesture(container, fullHandler);
|
||||
createTapGesture(container, regionHandler, { region: 'left' });
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 50 });
|
||||
|
||||
expect(regionHandler).toHaveBeenCalledOnce();
|
||||
expect(fullHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('full-surface fires outside named regions', () => {
|
||||
const container = setup();
|
||||
const regionHandler = vi.fn();
|
||||
const fullHandler = vi.fn();
|
||||
|
||||
createTapGesture(container, fullHandler);
|
||||
createTapGesture(container, regionHandler, { region: 'left' });
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 250 });
|
||||
|
||||
expect(fullHandler).toHaveBeenCalledOnce();
|
||||
expect(regionHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('full-surface tap fires alongside doubletap regions', () => {
|
||||
const container = setup();
|
||||
const tapHandler = vi.fn();
|
||||
const doubletapLeft = vi.fn();
|
||||
const doubletapRight = vi.fn();
|
||||
|
||||
createTapGesture(container, tapHandler);
|
||||
createDoubleTapGesture(container, doubletapLeft, { region: 'left' });
|
||||
createDoubleTapGesture(container, doubletapRight, { region: 'right' });
|
||||
|
||||
pointerDown(container);
|
||||
vi.advanceTimersByTime(50);
|
||||
pointerUp(container, { pointerType: 'mouse', clientX: 150 });
|
||||
|
||||
vi.advanceTimersByTime(DOUBLETAP_WINDOW);
|
||||
expect(tapHandler).toHaveBeenCalledOnce();
|
||||
expect(doubletapLeft).not.toHaveBeenCalled();
|
||||
expect(doubletapRight).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function pointerDown(target: HTMLElement): void {
|
||||
target.dispatchEvent(new Event('pointerdown', { bubbles: true }));
|
||||
}
|
||||
|
||||
function pointerUp(target: HTMLElement, init: { pointerType: string; clientX: number }): void {
|
||||
const event = new Event('pointerup', { bubbles: true });
|
||||
Object.defineProperty(event, 'pointerType', { value: init.pointerType });
|
||||
Object.defineProperty(event, 'clientX', { value: init.clientX });
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { GestureRegion } from '../gesture';
|
||||
import { resolveRegion } from '../region';
|
||||
|
||||
describe('resolveRegion', () => {
|
||||
const rect = { left: 0, width: 300 } as DOMRect;
|
||||
|
||||
describe('halves (left + right)', () => {
|
||||
const regions = new Set<GestureRegion>(['left', 'right']);
|
||||
|
||||
it('returns left for first half', () => {
|
||||
expect(resolveRegion(50, rect, regions)).toBe('left');
|
||||
expect(resolveRegion(0, rect, regions)).toBe('left');
|
||||
expect(resolveRegion(149, rect, regions)).toBe('left');
|
||||
});
|
||||
|
||||
it('returns right for second half', () => {
|
||||
expect(resolveRegion(150, rect, regions)).toBe('right');
|
||||
expect(resolveRegion(250, rect, regions)).toBe('right');
|
||||
expect(resolveRegion(300, rect, regions)).toBe('right');
|
||||
});
|
||||
});
|
||||
|
||||
describe('thirds (left + center + right)', () => {
|
||||
const regions = new Set<GestureRegion>(['left', 'center', 'right']);
|
||||
|
||||
it('returns left for first third', () => {
|
||||
expect(resolveRegion(0, rect, regions)).toBe('left');
|
||||
expect(resolveRegion(50, rect, regions)).toBe('left');
|
||||
expect(resolveRegion(99, rect, regions)).toBe('left');
|
||||
});
|
||||
|
||||
it('returns center for middle third', () => {
|
||||
expect(resolveRegion(100, rect, regions)).toBe('center');
|
||||
expect(resolveRegion(150, rect, regions)).toBe('center');
|
||||
expect(resolveRegion(199, rect, regions)).toBe('center');
|
||||
});
|
||||
|
||||
it('returns right for last third', () => {
|
||||
expect(resolveRegion(200, rect, regions)).toBe('right');
|
||||
expect(resolveRegion(250, rect, regions)).toBe('right');
|
||||
expect(resolveRegion(300, rect, regions)).toBe('right');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for empty regions', () => {
|
||||
expect(resolveRegion(100, rect, new Set())).toBe(null);
|
||||
});
|
||||
|
||||
it('returns null for zero-width container', () => {
|
||||
const zeroRect = { left: 0, width: 0 } as DOMRect;
|
||||
const regions = new Set<GestureRegion>(['left', 'right']);
|
||||
expect(resolveRegion(0, zeroRect, regions)).toBe(null);
|
||||
});
|
||||
|
||||
describe('single region', () => {
|
||||
it('left-only covers the left half', () => {
|
||||
const regions = new Set<GestureRegion>(['left']);
|
||||
expect(resolveRegion(0, rect, regions)).toBe('left');
|
||||
expect(resolveRegion(100, rect, regions)).toBe('left');
|
||||
// Right half returns null (full-surface gesture handles it).
|
||||
expect(resolveRegion(200, rect, regions)).toBe(null);
|
||||
});
|
||||
|
||||
it('center-only covers the whole surface', () => {
|
||||
const regions = new Set<GestureRegion>(['center']);
|
||||
expect(resolveRegion(0, rect, regions)).toBe('center');
|
||||
expect(resolveRegion(150, rect, regions)).toBe('center');
|
||||
expect(resolveRegion(299, rect, regions)).toBe('center');
|
||||
});
|
||||
|
||||
it('right-only covers the right half', () => {
|
||||
const regions = new Set<GestureRegion>(['right']);
|
||||
// Left half returns null.
|
||||
expect(resolveRegion(100, rect, regions)).toBe(null);
|
||||
expect(resolveRegion(150, rect, regions)).toBe('right');
|
||||
expect(resolveRegion(250, rect, regions)).toBe('right');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles offset container', () => {
|
||||
const offsetRect = { left: 100, width: 300 } as DOMRect;
|
||||
const regions = new Set<GestureRegion>(['left', 'right']);
|
||||
|
||||
// clientX 100 = left edge → left region
|
||||
expect(resolveRegion(100, offsetRect, regions)).toBe('left');
|
||||
// clientX 250 = middle → left region (< 50%)
|
||||
expect(resolveRegion(249, offsetRect, regions)).toBe('left');
|
||||
// clientX 250 = middle → right region (>= 50%)
|
||||
expect(resolveRegion(250, offsetRect, regions)).toBe('right');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GestureMatchResult, GestureType } from '../gesture';
|
||||
import { TapRecognizer } from '../tap';
|
||||
|
||||
const DOUBLETAP_WINDOW = 300;
|
||||
|
||||
const NOOP_RECOGNIZER = { handleUp: () => {}, reset: () => {} };
|
||||
|
||||
function createMatches(handlers: Partial<Record<GestureType, (() => void) | null>>): GestureMatchResult {
|
||||
return {
|
||||
resolve(type) {
|
||||
const handler = handlers[type];
|
||||
if (!handler) return [];
|
||||
return [{ type, recognizer: NOOP_RECOGNIZER, onActivate: handler }];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeEvent(): PointerEvent {
|
||||
return new Event('pointerup') as PointerEvent;
|
||||
}
|
||||
|
||||
describe('TapRecognizer', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('tap without doubletap', () => {
|
||||
it('fires tap immediately when no doubletap bindings', () => {
|
||||
const recognizer = new TapRecognizer();
|
||||
const onTap = vi.fn();
|
||||
|
||||
recognizer.handleUp(createMatches({ tap: onTap }), fakeEvent());
|
||||
|
||||
expect(onTap).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tap with doubletap', () => {
|
||||
it('defers tap when doubletap bindings exist', () => {
|
||||
const recognizer = new TapRecognizer();
|
||||
const onTap = vi.fn();
|
||||
const onDoubleTap = vi.fn();
|
||||
|
||||
recognizer.handleUp(createMatches({ tap: onTap, doubletap: onDoubleTap }), fakeEvent());
|
||||
|
||||
// Not fired yet — waiting for doubletap window.
|
||||
expect(onTap).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(DOUBLETAP_WINDOW);
|
||||
expect(onTap).toHaveBeenCalledOnce();
|
||||
expect(onDoubleTap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fires doubletap on two quick taps', () => {
|
||||
const recognizer = new TapRecognizer();
|
||||
const onTap = vi.fn();
|
||||
const onDoubleTap = vi.fn();
|
||||
|
||||
const matches = createMatches({ tap: onTap, doubletap: onDoubleTap });
|
||||
|
||||
// First tap
|
||||
recognizer.handleUp(matches, fakeEvent());
|
||||
|
||||
// Second tap within window
|
||||
vi.advanceTimersByTime(100);
|
||||
recognizer.handleUp(matches, fakeEvent());
|
||||
|
||||
expect(onDoubleTap).toHaveBeenCalledOnce();
|
||||
expect(onTap).not.toHaveBeenCalled();
|
||||
|
||||
// Deferred tap should not fire after doubletap.
|
||||
vi.advanceTimersByTime(DOUBLETAP_WINDOW);
|
||||
expect(onTap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fire doubletap when taps are too far apart', () => {
|
||||
const recognizer = new TapRecognizer();
|
||||
const onTap = vi.fn();
|
||||
const onDoubleTap = vi.fn();
|
||||
|
||||
const matches = createMatches({ tap: onTap, doubletap: onDoubleTap });
|
||||
|
||||
// First tap
|
||||
recognizer.handleUp(matches, fakeEvent());
|
||||
|
||||
// Wait for doubletap window to expire (tap fires).
|
||||
vi.advanceTimersByTime(DOUBLETAP_WINDOW);
|
||||
expect(onTap).toHaveBeenCalledOnce();
|
||||
|
||||
// Second tap outside window — new deferred tap, not doubletap.
|
||||
vi.advanceTimersByTime(100);
|
||||
recognizer.handleUp(matches, fakeEvent());
|
||||
|
||||
expect(onDoubleTap).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('clears pending tap timer', () => {
|
||||
const recognizer = new TapRecognizer();
|
||||
const onTap = vi.fn();
|
||||
|
||||
recognizer.handleUp(createMatches({ tap: onTap, doubletap: vi.fn() }), fakeEvent());
|
||||
|
||||
recognizer.reset();
|
||||
|
||||
vi.advanceTimersByTime(DOUBLETAP_WINDOW);
|
||||
expect(onTap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears doubletap state', () => {
|
||||
const recognizer = new TapRecognizer();
|
||||
const onDoubleTap = vi.fn();
|
||||
|
||||
// First tap
|
||||
recognizer.handleUp(createMatches({ doubletap: onDoubleTap }), fakeEvent());
|
||||
|
||||
recognizer.reset();
|
||||
|
||||
// Second tap after reset — should not count as doubletap.
|
||||
vi.advanceTimersByTime(50);
|
||||
recognizer.handleUp(createMatches({ doubletap: onDoubleTap }), fakeEvent());
|
||||
|
||||
expect(onDoubleTap).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
export * from './feature';
|
||||
export * from './gesture/coordinator';
|
||||
export * from './gesture/create-tap-gesture';
|
||||
export * from './gesture/gesture';
|
||||
export * from './hotkey/actions';
|
||||
export * from './hotkey/aria';
|
||||
export * from './hotkey/coordinator';
|
||||
|
||||
Reference in New Issue
Block a user