From d76f154f3661237c4727e96573e64b1e0dc47897 Mon Sep 17 00:00:00 2001 From: devphilip21 Date: Wed, 31 Dec 2025 12:43:07 +0900 Subject: [PATCH 1/7] fix(docs): improve layout container centering and remove duplicate styles - Add position: relative to .container for proper positioning context - Constrain container width with max-width and center with margin: auto - Remove duplicate .container style rule from layout.css --- docs/src/layouts/docs-layout.astro | 3 +++ docs/src/styles/custom-design/layout.css | 4 ---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/src/layouts/docs-layout.astro b/docs/src/layouts/docs-layout.astro index ecde65e..5fcfb64 100644 --- a/docs/src/layouts/docs-layout.astro +++ b/docs/src/layouts/docs-layout.astro @@ -94,7 +94,10 @@ const sidebar = getSidebar(currentPath); .container { display: flex; + position: relative; width: 100%; + max-width: 1200px !important; + margin: 0 auto; padding: var(--sl-header-height) var(--sl-nav-pad-x) 0 220px; box-sizing: border-box; } diff --git a/docs/src/styles/custom-design/layout.css b/docs/src/styles/custom-design/layout.css index bb7737c..483d212 100644 --- a/docs/src/styles/custom-design/layout.css +++ b/docs/src/styles/custom-design/layout.css @@ -18,10 +18,6 @@ body { @apply py-16! xl:py-24!; } -/* container */ -.container { - @apply mx-auto max-w-full! xl:max-w-[1740px]! px-4; -} .content-panel .sl-container { @apply mx-auto xl:max-w-[1740px]! ; } From 473efe6eb74e31822536395b1d4b6bb2e7268fb1 Mon Sep 17 00:00:00 2001 From: devphilip21 Date: Sun, 4 Jan 2026 17:51:43 +0900 Subject: [PATCH 2/7] feat(tap): add tap gesture recognition with multi-tap support - Implement TapRecognizer for detecting single/multi-tap gestures - Add tapRecognizer and tapEndOnly operators for stream integration - Support double-tap, triple-tap detection with configurable thresholds - Include comprehensive test coverage for recognizer, state, and geometry - Add TypeScript configuration and build tooling --- packages/tap/package.json | 61 ++++++++ packages/tap/src/geometry.spec.ts | 28 ++++ packages/tap/src/geometry.ts | 8 ++ packages/tap/src/index.ts | 4 + packages/tap/src/recognizer.spec.ts | 196 ++++++++++++++++++++++++++ packages/tap/src/recognizer.ts | 208 ++++++++++++++++++++++++++++ packages/tap/src/state.spec.ts | 74 ++++++++++ packages/tap/src/state.ts | 71 ++++++++++ packages/tap/src/tap-signal.ts | 58 ++++++++ packages/tap/src/tap-types.ts | 15 ++ packages/tap/src/tap.ts | 110 +++++++++++++++ packages/tap/tsconfig.json | 26 ++++ packages/tap/vite.config.ts | 20 +++ packages/tap/vitest.config.ts | 15 ++ pnpm-lock.yaml | 25 ++++ 15 files changed, 919 insertions(+) create mode 100644 packages/tap/package.json create mode 100644 packages/tap/src/geometry.spec.ts create mode 100644 packages/tap/src/geometry.ts create mode 100644 packages/tap/src/index.ts create mode 100644 packages/tap/src/recognizer.spec.ts create mode 100644 packages/tap/src/recognizer.ts create mode 100644 packages/tap/src/state.spec.ts create mode 100644 packages/tap/src/state.ts create mode 100644 packages/tap/src/tap-signal.ts create mode 100644 packages/tap/src/tap-types.ts create mode 100644 packages/tap/src/tap.ts create mode 100644 packages/tap/tsconfig.json create mode 100644 packages/tap/vite.config.ts create mode 100644 packages/tap/vitest.config.ts diff --git a/packages/tap/package.json b/packages/tap/package.json new file mode 100644 index 0000000..84efb8a --- /dev/null +++ b/packages/tap/package.json @@ -0,0 +1,61 @@ +{ + "name": "@cereb/tap", + "description": "Tap gesture recognition with multi-tap support, designed for reactive streams.", + "version": "0.1.0", + "license": "MIT", + "author": "devphilip21 ", + "repository": { + "type": "git", + "url": "https://github.com/devphilip21/cereb.git", + "directory": "packages/tap" + }, + "homepage": "https://cereb.dev/stream-api/tap", + "bugs": { + "url": "https://github.com/devphilip21/cereb/issues" + }, + "keywords": [ + "tap", + "click", + "gesture", + "touch", + "pointer", + "double-tap", + "cereb" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "builder build", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "cereb": "workspace:^" + }, + "peerDependencies": { + "cereb": "workspace:^" + }, + "devDependencies": { + "@cereb/builder": "workspace:^", + "@vitest/coverage-v8": "*", + "jsdom": "*", + "vite": "*", + "vitest": "*" + } +} diff --git a/packages/tap/src/geometry.spec.ts b/packages/tap/src/geometry.spec.ts new file mode 100644 index 0000000..b7dfb4e --- /dev/null +++ b/packages/tap/src/geometry.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { calculateDistance } from "./geometry.js"; + +describe("calculateDistance", () => { + it("should calculate horizontal distance", () => { + const distance = calculateDistance(0, 0, 100, 0); + + expect(distance).toBe(100); + }); + + it("should calculate vertical distance", () => { + const distance = calculateDistance(0, 0, 0, 100); + + expect(distance).toBe(100); + }); + + it("should calculate diagonal distance using Pythagorean theorem", () => { + const distance = calculateDistance(0, 0, 3, 4); + + expect(distance).toBe(5); + }); + + it("should return zero for same points", () => { + const distance = calculateDistance(50, 50, 50, 50); + + expect(distance).toBe(0); + }); +}); diff --git a/packages/tap/src/geometry.ts b/packages/tap/src/geometry.ts new file mode 100644 index 0000000..cf9f7c6 --- /dev/null +++ b/packages/tap/src/geometry.ts @@ -0,0 +1,8 @@ +/** + * Calculate Euclidean distance between two points. + */ +export function calculateDistance(x1: number, y1: number, x2: number, y2: number): number { + const dx = x2 - x1; + const dy = y2 - y1; + return Math.sqrt(dx * dx + dy * dy); +} diff --git a/packages/tap/src/index.ts b/packages/tap/src/index.ts new file mode 100644 index 0000000..dcea86e --- /dev/null +++ b/packages/tap/src/index.ts @@ -0,0 +1,4 @@ +export { createTapRecognizer, type TapRecognizer } from "./recognizer.js"; +export { tap, tapEndOnly, tapRecognizer } from "./tap.js"; +export type { TapSignal, TapValue } from "./tap-signal.js"; +export type { TapOptions, TapPhase } from "./tap-types.js"; diff --git a/packages/tap/src/recognizer.spec.ts b/packages/tap/src/recognizer.spec.ts new file mode 100644 index 0000000..79b7441 --- /dev/null +++ b/packages/tap/src/recognizer.spec.ts @@ -0,0 +1,196 @@ +import type { SinglePointerSignal } from "cereb"; +import { describe, expect, it } from "vitest"; +import { createTapRecognizer } from "./recognizer.js"; + +function createMockPointerSignal( + phase: "start" | "move" | "end" | "cancel", + x: number, + y: number, + createdAt: number, +): SinglePointerSignal { + return { + kind: "single-pointer", + value: { + phase, + x, + y, + pageX: x, + pageY: y, + pointerType: "mouse", + button: "primary", + pressure: 0.5, + id: "1", + }, + deviceId: "test-device", + createdAt, + }; +} + +describe("createTapRecognizer", () => { + describe("single tap recognition", () => { + it("should emit start phase on pointer start", () => { + const recognizer = createTapRecognizer(); + + const result = recognizer.process(createMockPointerSignal("start", 100, 100, 0)); + + expect(result).not.toBeNull(); + expect(result?.value.phase).toBe("start"); + expect(result?.value.tapCount).toBe(1); + expect(result?.value.x).toBe(100); + expect(result?.value.y).toBe(100); + }); + + it("should emit end phase on pointer end within thresholds", () => { + const recognizer = createTapRecognizer(); + + recognizer.process(createMockPointerSignal("start", 100, 100, 0)); + const result = recognizer.process(createMockPointerSignal("end", 100, 100, 100)); + + expect(result).not.toBeNull(); + expect(result?.value.phase).toBe("end"); + expect(result?.value.tapCount).toBe(1); + expect(result?.value.duration).toBe(100); + }); + + it("should not emit on move within threshold", () => { + const recognizer = createTapRecognizer({ movementThreshold: 10 }); + + recognizer.process(createMockPointerSignal("start", 100, 100, 0)); + const result = recognizer.process(createMockPointerSignal("move", 105, 105, 50)); + + expect(result).toBeNull(); + }); + }); + + describe("tap cancellation", () => { + it("should cancel on movement exceeding threshold", () => { + const recognizer = createTapRecognizer({ movementThreshold: 10 }); + + recognizer.process(createMockPointerSignal("start", 100, 100, 0)); + const result = recognizer.process(createMockPointerSignal("move", 120, 100, 50)); + + expect(result).not.toBeNull(); + expect(result?.value.phase).toBe("cancel"); + expect(result?.value.tapCount).toBe(0); + }); + + it("should cancel on duration exceeding threshold", () => { + const recognizer = createTapRecognizer({ durationThreshold: 500 }); + + recognizer.process(createMockPointerSignal("start", 100, 100, 0)); + const result = recognizer.process(createMockPointerSignal("move", 100, 100, 600)); + + expect(result).not.toBeNull(); + expect(result?.value.phase).toBe("cancel"); + }); + + it("should cancel on pointer cancel event", () => { + const recognizer = createTapRecognizer(); + + recognizer.process(createMockPointerSignal("start", 100, 100, 0)); + const result = recognizer.process(createMockPointerSignal("cancel", 100, 100, 50)); + + expect(result).not.toBeNull(); + expect(result?.value.phase).toBe("cancel"); + }); + + it("should not emit end after cancellation", () => { + const recognizer = createTapRecognizer({ movementThreshold: 10 }); + + recognizer.process(createMockPointerSignal("start", 100, 100, 0)); + recognizer.process(createMockPointerSignal("move", 120, 100, 50)); + const result = recognizer.process(createMockPointerSignal("end", 120, 100, 100)); + + expect(result).toBeNull(); + }); + }); + + describe("double tap detection", () => { + it("should increment tapCount for consecutive taps within interval", () => { + const recognizer = createTapRecognizer({ + chainIntervalThreshold: 300, + chainMovementThreshold: 25, + }); + + recognizer.process(createMockPointerSignal("start", 100, 100, 0)); + recognizer.process(createMockPointerSignal("end", 100, 100, 100)); + + const startResult = recognizer.process(createMockPointerSignal("start", 105, 105, 200)); + const endResult = recognizer.process(createMockPointerSignal("end", 105, 105, 250)); + + expect(startResult?.value.tapCount).toBe(2); + expect(endResult?.value.tapCount).toBe(2); + }); + + it("should reset tapCount when interval exceeded", () => { + const recognizer = createTapRecognizer({ chainIntervalThreshold: 300 }); + + recognizer.process(createMockPointerSignal("start", 100, 100, 0)); + recognizer.process(createMockPointerSignal("end", 100, 100, 100)); + + const startResult = recognizer.process(createMockPointerSignal("start", 100, 100, 500)); + + expect(startResult?.value.tapCount).toBe(1); + }); + + it("should reset tapCount when distance exceeded", () => { + const recognizer = createTapRecognizer({ + chainIntervalThreshold: 300, + chainMovementThreshold: 25, + }); + + recognizer.process(createMockPointerSignal("start", 100, 100, 0)); + recognizer.process(createMockPointerSignal("end", 100, 100, 100)); + + const startResult = recognizer.process(createMockPointerSignal("start", 150, 150, 200)); + + expect(startResult?.value.tapCount).toBe(1); + }); + }); + + describe("triple tap and beyond", () => { + it("should support triple tap", () => { + const recognizer = createTapRecognizer({ + chainIntervalThreshold: 300, + chainMovementThreshold: 25, + }); + + recognizer.process(createMockPointerSignal("start", 100, 100, 0)); + recognizer.process(createMockPointerSignal("end", 100, 100, 50)); + + recognizer.process(createMockPointerSignal("start", 100, 100, 150)); + recognizer.process(createMockPointerSignal("end", 100, 100, 200)); + + const thirdStart = recognizer.process(createMockPointerSignal("start", 100, 100, 300)); + const thirdEnd = recognizer.process(createMockPointerSignal("end", 100, 100, 350)); + + expect(thirdStart?.value.tapCount).toBe(3); + expect(thirdEnd?.value.tapCount).toBe(3); + }); + }); + + describe("reset and dispose", () => { + it("should reset state", () => { + const recognizer = createTapRecognizer(); + + recognizer.process(createMockPointerSignal("start", 100, 100, 0)); + expect(recognizer.isActive).toBe(true); + + recognizer.reset(); + expect(recognizer.isActive).toBe(false); + }); + + it("should reset multi-tap tracking on reset", () => { + const recognizer = createTapRecognizer({ chainIntervalThreshold: 300 }); + + recognizer.process(createMockPointerSignal("start", 100, 100, 0)); + recognizer.process(createMockPointerSignal("end", 100, 100, 50)); + + recognizer.reset(); + + const result = recognizer.process(createMockPointerSignal("start", 100, 100, 100)); + + expect(result?.value.tapCount).toBe(1); + }); + }); +}); diff --git a/packages/tap/src/recognizer.ts b/packages/tap/src/recognizer.ts new file mode 100644 index 0000000..d3d2811 --- /dev/null +++ b/packages/tap/src/recognizer.ts @@ -0,0 +1,208 @@ +import type { SinglePointerSignal } from "cereb"; +import { calculateDistance } from "./geometry.js"; +import { createInitialTapState, resetCurrentTap, resetTapState, type TapState } from "./state.js"; +import { createTapSignal, type TapSignal } from "./tap-signal.js"; +import type { TapOptions, TapPhase } from "./tap-types.js"; + +const DEFAULT_MOVEMENT_THRESHOLD = 10; +const DEFAULT_DURATION_THRESHOLD = 500; + +/** + * Stateful processor that transforms SinglePointer events into TapSignal. + * Supports multi-tap detection (double-tap, triple-tap, etc.) + */ +export interface TapRecognizer { + process(pointer: SinglePointerSignal): TapSignal | null; + readonly isActive: boolean; + reset(): void; + dispose(): void; +} + +/** + * Creates a tap gesture recognizer that processes SinglePointer events. + * + * The recognizer maintains internal state and can be used: + * - Imperatively via process() method + * - With any event source (not just Observable streams) + * + * @example + * ```typescript + * const recognizer = createTapRecognizer({ durationThreshold: 300 }); + * + * singlePointerStream.on((signal) => { + * const tapEvent = recognizer.process(signal); + * if (tapEvent?.value.phase === "end") { + * console.log(`Tap ${tapEvent.value.tapCount}!`); + * } + * }); + * ``` + */ +export function createTapRecognizer(options: TapOptions = {}): TapRecognizer { + const { + movementThreshold = DEFAULT_MOVEMENT_THRESHOLD, + durationThreshold = DEFAULT_DURATION_THRESHOLD, + } = options; + + const chainMovementThreshold = options.chainMovementThreshold ?? movementThreshold; + const chainIntervalThreshold = options.chainIntervalThreshold ?? durationThreshold / 2; + + const state: TapState = createInitialTapState(); + + function createTapSignalFromState( + pointerSignal: SinglePointerSignal, + phase: TapPhase, + tapCount: number, + ): TapSignal { + const duration = pointerSignal.createdAt - state.startTimestamp; + + return createTapSignal({ + phase, + x: state.startX, + y: state.startY, + pageX: state.startPageX, + pageY: state.startPageY, + tapCount, + duration: Math.max(0, duration), + pointerType: pointerSignal.value.pointerType, + }); + } + + function shouldIncrementTapCount( + currentX: number, + currentY: number, + currentTimestamp: number, + ): boolean { + if (state.lastTapEndTimestamp === 0) { + return false; + } + + const timeSinceLastTap = currentTimestamp - state.lastTapEndTimestamp; + if (timeSinceLastTap > chainIntervalThreshold) { + return false; + } + + const distance = calculateDistance(state.lastTapX, state.lastTapY, currentX, currentY); + if (distance > chainMovementThreshold) { + return false; + } + + return true; + } + + function handleStart(signal: SinglePointerSignal): TapSignal { + const { x, y, pageX, pageY, pointerType } = signal.value; + + const continuesMultiTap = shouldIncrementTapCount(x, y, signal.createdAt); + + state.isActive = true; + state.startX = x; + state.startY = y; + state.startPageX = pageX; + state.startPageY = pageY; + state.startTimestamp = signal.createdAt; + state.deviceId = signal.deviceId; + state.pointerType = pointerType; + state.isCancelled = false; + + if (continuesMultiTap) { + state.currentTapCount += 1; + } else { + state.currentTapCount = 1; + } + + return createTapSignalFromState(signal, "start", state.currentTapCount); + } + + function handleMove(signal: SinglePointerSignal): TapSignal | null { + if (!state.isActive || state.isCancelled) return null; + + const { x, y } = signal.value; + + const movement = calculateDistance(state.startX, state.startY, x, y); + if (movement > movementThreshold) { + state.isCancelled = true; + state.lastTapEndTimestamp = 0; + state.currentTapCount = 0; + return createTapSignalFromState(signal, "cancel", 0); + } + + const duration = signal.createdAt - state.startTimestamp; + if (duration > durationThreshold) { + state.isCancelled = true; + state.lastTapEndTimestamp = 0; + state.currentTapCount = 0; + return createTapSignalFromState(signal, "cancel", 0); + } + + return null; + } + + function handleEnd(signal: SinglePointerSignal): TapSignal | null { + if (!state.isActive) return null; + + if (state.isCancelled) { + resetCurrentTap(state); + return null; + } + + const duration = signal.createdAt - state.startTimestamp; + if (duration > durationThreshold) { + state.lastTapEndTimestamp = 0; + state.currentTapCount = 0; + const result = createTapSignalFromState(signal, "cancel", 0); + resetCurrentTap(state); + return result; + } + + const tapCount = state.currentTapCount; + state.lastTapEndTimestamp = signal.createdAt; + state.lastTapX = state.startX; + state.lastTapY = state.startY; + + const result = createTapSignalFromState(signal, "end", tapCount); + resetCurrentTap(state); + + return result; + } + + function handleCancel(signal: SinglePointerSignal): TapSignal | null { + if (!state.isActive) return null; + + state.lastTapEndTimestamp = 0; + state.currentTapCount = 0; + + const result = state.isCancelled ? null : createTapSignalFromState(signal, "cancel", 0); + + resetCurrentTap(state); + return result; + } + + return { + process(signal: SinglePointerSignal): TapSignal | null { + switch (signal.value.phase) { + case "start": + return handleStart(signal); + case "move": + return handleMove(signal); + case "end": + return handleEnd(signal); + case "cancel": + return handleCancel(signal); + default: + return null; + } + }, + + get isActive(): boolean { + return state.isActive; + }, + + reset(): void { + resetTapState(state); + }, + + dispose(): void { + resetTapState(state); + }, + }; +} diff --git a/packages/tap/src/state.spec.ts b/packages/tap/src/state.spec.ts new file mode 100644 index 0000000..74f7297 --- /dev/null +++ b/packages/tap/src/state.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { createInitialTapState, resetCurrentTap, resetTapState } from "./state.js"; + +describe("createInitialTapState", () => { + it("should create state with initial values", () => { + const state = createInitialTapState(); + + expect(state.isActive).toBe(false); + expect(state.startX).toBe(0); + expect(state.startY).toBe(0); + expect(state.startPageX).toBe(0); + expect(state.startPageY).toBe(0); + expect(state.startTimestamp).toBe(0); + expect(state.deviceId).toBe(""); + expect(state.pointerType).toBe("unknown"); + expect(state.lastTapEndTimestamp).toBe(0); + expect(state.lastTapX).toBe(0); + expect(state.lastTapY).toBe(0); + expect(state.currentTapCount).toBe(0); + expect(state.isCancelled).toBe(false); + }); +}); + +describe("resetCurrentTap", () => { + it("should reset current tap fields but preserve multi-tap tracking", () => { + const state = createInitialTapState(); + state.isActive = true; + state.startX = 100; + state.startY = 200; + state.startTimestamp = 1000; + state.deviceId = "mouse-1"; + state.isCancelled = true; + state.lastTapEndTimestamp = 500; + state.lastTapX = 50; + state.lastTapY = 60; + state.currentTapCount = 2; + + resetCurrentTap(state); + + expect(state.isActive).toBe(false); + expect(state.startX).toBe(0); + expect(state.startY).toBe(0); + expect(state.isCancelled).toBe(false); + expect(state.lastTapEndTimestamp).toBe(500); + expect(state.lastTapX).toBe(50); + expect(state.lastTapY).toBe(60); + expect(state.currentTapCount).toBe(2); + }); +}); + +describe("resetTapState", () => { + it("should reset all fields to initial values", () => { + const state = createInitialTapState(); + state.isActive = true; + state.startX = 100; + state.startY = 200; + state.startTimestamp = 1000; + state.deviceId = "mouse-1"; + state.lastTapEndTimestamp = 500; + state.lastTapX = 50; + state.lastTapY = 60; + state.currentTapCount = 2; + + resetTapState(state); + + expect(state.isActive).toBe(false); + expect(state.startX).toBe(0); + expect(state.startY).toBe(0); + expect(state.lastTapEndTimestamp).toBe(0); + expect(state.lastTapX).toBe(0); + expect(state.lastTapY).toBe(0); + expect(state.currentTapCount).toBe(0); + }); +}); diff --git a/packages/tap/src/state.ts b/packages/tap/src/state.ts new file mode 100644 index 0000000..93a63eb --- /dev/null +++ b/packages/tap/src/state.ts @@ -0,0 +1,71 @@ +/** + * Internal state for tap gesture tracking. + * Tracks both current tap attempt and history for multi-tap detection. + */ +export interface TapState { + /** Whether a tap attempt is currently in progress */ + isActive: boolean; + + /** Start position X */ + startX: number; + /** Start position Y */ + startY: number; + /** Page coordinates at start */ + startPageX: number; + startPageY: number; + /** Timestamp when tap started */ + startTimestamp: number; + /** Device identifier */ + deviceId: string; + /** Pointer type (touch, mouse, pen) */ + pointerType: string; + + /** Timestamp of last successful tap end */ + lastTapEndTimestamp: number; + /** Position of last successful tap */ + lastTapX: number; + lastTapY: number; + /** Current consecutive tap count */ + currentTapCount: number; + + /** Whether current tap attempt has been cancelled due to movement/duration */ + isCancelled: boolean; +} + +export function createInitialTapState(): TapState { + return { + isActive: false, + startX: 0, + startY: 0, + startPageX: 0, + startPageY: 0, + startTimestamp: 0, + deviceId: "", + pointerType: "unknown", + lastTapEndTimestamp: 0, + lastTapX: 0, + lastTapY: 0, + currentTapCount: 0, + isCancelled: false, + }; +} + +export function resetCurrentTap(state: TapState): void { + state.isActive = false; + state.startX = 0; + state.startY = 0; + state.startPageX = 0; + state.startPageY = 0; + state.startTimestamp = 0; + state.deviceId = ""; + state.pointerType = "unknown"; + state.isCancelled = false; +} + +export function resetTapState(state: TapState): void { + resetCurrentTap(state); + state.lastTapEndTimestamp = 0; + state.lastTapX = 0; + state.lastTapY = 0; + state.currentTapCount = 0; +} diff --git a/packages/tap/src/tap-signal.ts b/packages/tap/src/tap-signal.ts new file mode 100644 index 0000000..62cfafc --- /dev/null +++ b/packages/tap/src/tap-signal.ts @@ -0,0 +1,58 @@ +import type { Signal, SinglePointerType } from "cereb"; +import { createSignal } from "cereb"; +import type { TapPhase } from "./tap-types.js"; + +/** + * Tap gesture value emitted during tap lifecycle (start, end, cancel). + * Contains position, timing, and consecutive tap count information. + */ +export interface TapValue { + phase: TapPhase; + + /** Tap position X (client coordinates) */ + x: number; + /** Tap position Y (client coordinates) */ + y: number; + + /** Tap position X (page coordinates) */ + pageX: number; + /** Tap position Y (page coordinates) */ + pageY: number; + + /** + * Number of consecutive taps (1=single, 2=double, 3=triple, etc.) + * Increments if taps occur within multiTapInterval and multiTapDistance. + */ + tapCount: number; + + /** How long the pointer was pressed (ms) */ + duration: number; + + /** Type of pointer that performed the tap */ + pointerType: SinglePointerType; +} + +export interface TapSignal extends Signal<"tap", TapValue & T> {} + +export const TAP_SIGNAL_KIND = "tap" as const; + +export function createDefaultTapValue(): TapValue { + return { + phase: "end", + x: 0, + y: 0, + pageX: 0, + pageY: 0, + tapCount: 1, + duration: 0, + pointerType: "unknown", + }; +} + +export function createDefaultTapSignal(): TapSignal { + return createSignal(TAP_SIGNAL_KIND, createDefaultTapValue()); +} + +export function createTapSignal(value: TapValue): TapSignal { + return createSignal(TAP_SIGNAL_KIND, value); +} diff --git a/packages/tap/src/tap-types.ts b/packages/tap/src/tap-types.ts new file mode 100644 index 0000000..a92819a --- /dev/null +++ b/packages/tap/src/tap-types.ts @@ -0,0 +1,15 @@ +export type TapPhase = "start" | "end" | "cancel"; + +export interface TapOptions { + /** Max movement allowed during tap (default: 10px) */ + movementThreshold?: number; + + /** Max duration for a valid tap (default: 500ms) */ + durationThreshold?: number; + + /** Max distance between consecutive taps (default: movementThreshold) */ + chainMovementThreshold?: number; + + /** Max interval between consecutive taps (default: durationThreshold / 2) */ + chainIntervalThreshold?: number; +} diff --git a/packages/tap/src/tap.ts b/packages/tap/src/tap.ts new file mode 100644 index 0000000..3ef416e --- /dev/null +++ b/packages/tap/src/tap.ts @@ -0,0 +1,110 @@ +import type { Operator, SinglePointerSignal, Stream } from "cereb"; +import { createStream, singlePointer } from "cereb"; +import { createTapRecognizer } from "./recognizer.js"; +import type { TapSignal } from "./tap-signal.js"; +import type { TapOptions } from "./tap-types.js"; + +/** + * Operator that transforms SinglePointer events into TapSignal events. + * Emits "start", "end", and "cancel" phases for full tap lifecycle visibility. + * + * Use this when you need to: + * - Show visual feedback on tap start + * - Handle tap cancellation + * - Compose with other operators + * + * @example + * ```typescript + * singlePointer(element) + * .pipe(tapRecognizer({ maxDuration: 300 })) + * .on((signal) => { + * if (signal.value.phase === "start") { + * element.classList.add("pressed"); + * } else { + * element.classList.remove("pressed"); + * } + * }); + * ``` + */ +export function tapRecognizer(options: TapOptions = {}): Operator { + return (source) => + createStream((observer) => { + const recognizer = createTapRecognizer(options); + + const unsub = source.on({ + next(pointer) { + const event = recognizer.process(pointer); + if (event) { + observer.next(event); + } + }, + error: observer.error?.bind(observer), + complete() { + observer.complete?.(); + }, + }); + + return () => { + recognizer.dispose(); + unsub(); + }; + }); +} + +/** + * Operator that only emits successful tap events (phase === "end"). + * Filters out "start" and "cancel" phases for simpler tap handling. + * + * @example + * ```typescript + * singlePointer(element) + * .pipe(tapEndOnly({ multiTapInterval: 300 })) + * .on((signal) => { + * if (signal.value.tapCount === 2) { + * console.log("Double tap detected!"); + * } + * }); + * ``` + */ +export function tapEndOnly(options: TapOptions = {}): Operator { + return (source) => + createStream((observer) => { + const recognizer = createTapRecognizer(options); + + const unsub = source.on({ + next(pointer) { + const event = recognizer.process(pointer); + if (event && event.value.phase === "end") { + observer.next(event); + } + }, + error: observer.error?.bind(observer), + complete() { + observer.complete?.(); + }, + }); + + return () => { + recognizer.dispose(); + unsub(); + }; + }); +} + +/** + * Creates a tap gesture stream from an element. + * Only emits successful tap events (phase === "end"). + * + * This is a convenience function that combines singlePointer and tap recognition. + * + * @example + * ```typescript + * tap(button, { multiTapInterval: 300 }) + * .on((signal) => { + * console.log(`Tap count: ${signal.value.tapCount}`); + * }); + * ``` + */ +export function tap(target: EventTarget, options: TapOptions = {}): Stream { + return singlePointer(target).pipe(tapEndOnly(options)); +} diff --git a/packages/tap/tsconfig.json b/packages/tap/tsconfig.json new file mode 100644 index 0000000..847ec1e --- /dev/null +++ b/packages/tap/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "composite": true, + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "noEmit": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../cereb" }] +} diff --git a/packages/tap/vite.config.ts b/packages/tap/vite.config.ts new file mode 100644 index 0000000..98e0664 --- /dev/null +++ b/packages/tap/vite.config.ts @@ -0,0 +1,20 @@ +import { resolve } from "node:path"; +import type { UserConfig } from "vite"; + +export default ({ dirname }: { dirname: string }): UserConfig => ({ + build: { + lib: { + entry: { + index: resolve(dirname, "src/index.ts"), + }, + formats: ["es", "cjs"], + fileName: (format, entryName) => { + const ext = format === "es" ? "js" : "cjs"; + return `${entryName}.${ext}`; + }, + }, + rollupOptions: { + external: ["cereb"], + }, + }, +}); diff --git a/packages/tap/vitest.config.ts b/packages/tap/vitest.config.ts new file mode 100644 index 0000000..2227609 --- /dev/null +++ b/packages/tap/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "jsdom", + include: ["src/**/*.spec.ts"], + coverage: { + provider: "v8", + reporter: ["text", "html"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.spec.ts"], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50d8967..12442bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: '@cereb/pinch': specifier: workspace:^ version: link:../packages/pinch + '@cereb/tap': + specifier: workspace:^ + version: link:../packages/tap '@tailwindcss/vite': specifier: ^4.1.17 version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2)) @@ -188,6 +191,28 @@ importers: specifier: '*' version: 2.1.9(@types/node@24.10.4)(jsdom@25.0.1)(lightningcss@1.30.2) + packages/tap: + dependencies: + cereb: + specifier: workspace:^ + version: link:../cereb + devDependencies: + '@cereb/builder': + specifier: workspace:^ + version: link:../../internal/builder + '@vitest/coverage-v8': + specifier: '*' + version: 2.1.9(vitest@2.1.9(@types/node@24.10.4)(jsdom@25.0.1)(lightningcss@1.30.2)) + jsdom: + specifier: '*' + version: 25.0.1 + vite: + specifier: '*' + version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2) + vitest: + specifier: '*' + version: 2.1.9(@types/node@24.10.4)(jsdom@25.0.1)(lightningcss@1.30.2) + packages: '@ampproject/remapping@2.3.0': From 481797e9c814e52c66f5d6a07d8b6c67be01a641 Mon Sep 17 00:00:00 2001 From: devphilip21 Date: Sun, 4 Jan 2026 17:52:47 +0900 Subject: [PATCH 3/7] feat(docs): add double-tap zoom gesture to space adventure example - Integrate tap gesture recognition with zoom operator - Enable 2.5x zoom multiplier on double-tap, with smart reset near max scale - Update tip text to indicate double-tap as zoom option --- docs/package.json | 1 + .../components/examples/space-adventure.astro | 21 +++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/package.json b/docs/package.json index f55c457..7e0593f 100644 --- a/docs/package.json +++ b/docs/package.json @@ -12,6 +12,7 @@ "@astrojs/mdx": "^4.3.0", "@cereb/pan": "workspace:^", "@cereb/pinch": "workspace:^", + "@cereb/tap": "workspace:^", "@tailwindcss/vite": "^4.1.17", "astro": "^5.16.4", "astro-embed": "^0.9.2", diff --git a/docs/src/components/examples/space-adventure.astro b/docs/src/components/examples/space-adventure.astro index d665116..ae42f37 100644 --- a/docs/src/components/examples/space-adventure.astro +++ b/docs/src/components/examples/space-adventure.astro @@ -12,7 +12,7 @@ import "./space-adventure.css"; Zoom Mode
Tip. - Pinch or change slider to zoom.
+ Pinch, double tap, or change slider to zoom.
In Desktop, use 'z' + '+/-' or 'wheel' to zoom.
@@ -42,8 +42,9 @@ import "./space-adventure.css";