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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,4 @@ dist
.astro

/blog
/tmp_docs
1 change: 1 addition & 0 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 19 additions & 2 deletions docs/src/components/examples/space-adventure.astro
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import "./space-adventure.css";
<strong id="space-zoom-mode" class="_zoom-mode">Zoom Mode</strong>
<div class="_tip">
<strong class="_tip-title">Tip.</strong>
Pinch or change slider to zoom.<br />
Pinch, double tap, or change slider to zoom.<br />
In Desktop, use 'z' + '+/-' or 'wheel' to zoom.
</div>
</div>
Expand Down Expand Up @@ -42,8 +42,9 @@ import "./space-adventure.css";

<script>
import { wheel, keydown, keyheld, type KeyboardSignal, type WheelSignal, domEvent, type KeyboardValue, type Signal } from "cereb";
import { zoom as createZoom, extend, when, spy, type ZoomInput, type ZoomOptions } from "cereb/operators";
import { zoom as createZoom, extend, when, spy, filter, type ZoomInput, type ZoomOptions } from "cereb/operators";
import { pinch } from "@cereb/pinch";
import { tap, type TapSignal } from "@cereb/tap";

interface Star {
x: number;
Expand Down Expand Up @@ -819,6 +820,22 @@ function initSpacePinchZoom() {
)
.on(render);

// Double Tap Zoom
const DOUBLE_TAP_ZOOM_MULTIPLIER = 2.5;
tap(box, { chainIntervalThreshold: 300 })
.pipe(
filter((signal) => signal.value.tapCount === 2),
extend<TapSignal, ZoomInput>(() => {
const currentScale = zoomManager.getScale();
const isNearMax = currentScale >= MAX_SCALE * 0.9;
return {
ratio: isNearMax ? 1 : Math.min(currentScale * DOUBLE_TAP_ZOOM_MULTIPLIER, MAX_SCALE),
};
}),
zoom({ baseScale: 1.0 }),
)
.on(render);

// 'z' + '+/-' (logarithmic zoom: multiply by 1.2 or 1/1.2)
keydown(window, { code: ["Equal", "Minus"] })
.pipe(
Expand Down
4 changes: 4 additions & 0 deletions docs/src/config/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
"label": "pinch",
"slug": "stream-api/pinch"
},
{
"label": "tap",
"slug": "stream-api/tap"
},
{
"label": "singlePointer",
"slug": "stream-api/single-pointer"
Expand Down
35 changes: 35 additions & 0 deletions docs/src/content/stream-api/pan.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,38 @@ singlePointer(element)
.pipe(panRecognizer({ threshold: 10 }))
.on((signal) => { /* ... */ });
```

## Advanced: createPanRecognizer

Low-level API for imperative usage or custom integrations.
The recognizer accepts any signal that satisfies `PanSourceSignal` interface.

```typescript
import { createPanRecognizer, type PanSourceSignal } from "@cereb/pan";

const recognizer = createPanRecognizer({ threshold: 10 });

// Works with any source that provides the required properties
function handlePointerEvent(signal: PanSourceSignal) {
const panEvent = recognizer.process(signal);
if (panEvent) {
console.log(panEvent.value.deltaX, panEvent.value.velocityX);
}
}
```

### PanSourceSignal Interface

```typescript
interface PanSourceSignal {
value: {
phase: "start" | "move" | "end" | "cancel";
x: number;
y: number;
pageX: number;
pageY: number;
};
createdAt: number;
deviceId: string;
}
```
40 changes: 40 additions & 0 deletions docs/src/content/stream-api/pinch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,43 @@ multiPointer(element, { maxPointers: 2 })
)
.on((signal) => { /* ... */ });
```

## Advanced: createPinchRecognizer

Low-level API for imperative usage or custom integrations.
The recognizer accepts any signal that satisfies `PinchSourceSignal` interface.

```typescript
import { createPinchRecognizer, type PinchSourceSignal } from "@cereb/pinch";

const recognizer = createPinchRecognizer({ threshold: 10 });

// Works with any source that provides the required properties
function handleMultiPointerEvent(signal: PinchSourceSignal) {
const pinchEvent = recognizer.process(signal);
if (pinchEvent) {
console.log(pinchEvent.value.distance, pinchEvent.value.velocity);
}
}
```

### PinchSourceSignal Interface

```typescript
interface PinchSourcePointer {
id: string;
phase: "start" | "move" | "end" | "cancel";
x: number;
y: number;
pageX: number;
pageY: number;
}

interface PinchSourceSignal {
value: {
pointers: readonly PinchSourcePointer[];
};
createdAt: number;
deviceId: string;
}
```
176 changes: 176 additions & 0 deletions docs/src/content/stream-api/tap.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# `tap`

Tap gesture recognition with multi-tap support. Detects single, double, triple taps and beyond with configurable timing and distance thresholds.

```bash
npm install --save @cereb/tap
```

## Basic Usage

```typescript
import { tap } from "@cereb/tap";

tap(element).on((signal) => {
const { tapCount, x, y } = signal.value;

if (tapCount === 1) {
console.log("Single tap");
} else if (tapCount === 2) {
console.log("Double tap - zoom in!");
}
});
```

## Signature

```typescript
function tap(target: EventTarget, options?: TapOptions): Stream<TapSignal>
```

## Options

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `movementThreshold` | `number` | `10` | Max movement (px) allowed during tap |
| `durationThreshold` | `number` | `500` | Max duration (ms) for a valid tap |
| `chainMovementThreshold` | `number` | `movementThreshold` | Max distance between consecutive taps |
| `chainIntervalThreshold` | `number` | `durationThreshold / 2` | Max interval (ms) between consecutive taps |

### Multi-Tap Configuration

```typescript
// Fast double-tap detection
tap(element, {
durationThreshold: 300,
chainIntervalThreshold: 250
})

// Strict tap positioning
tap(element, {
movementThreshold: 5,
chainMovementThreshold: 20
})
```

## Signal Value

The `signal.value` contains:

| Property | Type | Description |
|----------|------|-------------|
| `phase` | `"start" \| "end" \| "cancel"` | Current gesture phase |
| `x` | `number` | Tap X position (clientX) |
| `y` | `number` | Tap Y position (clientY) |
| `pageX` | `number` | Tap X position (pageX) |
| `pageY` | `number` | Tap Y position (pageY) |
| `tapCount` | `number` | Consecutive tap count (1, 2, 3, ...) |
| `duration` | `number` | How long pointer was pressed (ms) |
| `pointerType` | `"mouse" \| "touch" \| "pen" \| "unknown"` | Input device type |

## Phase Lifecycle

```
pointer down → "start" → pointer up (within thresholds) → "end"
→ moved too far / held too long → "cancel"
```

- **start**: Pointer pressed down
- **end**: Valid tap completed (tapCount incremented if chained)
- **cancel**: Tap invalidated (moved too far or held too long)

## Multi-Tap Chaining

Taps are chained when:
1. Time between taps is less than `chainIntervalThreshold`
2. Distance between tap positions is less than `chainMovementThreshold`

```
tap → 200ms → tap → 200ms → tap = tapCount: 3 (triple tap)
tap → 500ms → tap = tapCount: 1 (chain reset)
```

## With Visual Feedback

Use `tapRecognizer` for full lifecycle handling:

```typescript
import { singlePointer } from "cereb";
import { tapRecognizer } from "@cereb/tap";

singlePointer(element)
.pipe(tapRecognizer())
.on((signal) => {
const { phase } = signal.value;

if (phase === "start") {
element.classList.add("pressed");
} else {
element.classList.remove("pressed");
}
});
```

## Advanced: tapRecognizer

Use as an operator with custom pointer sources:

```typescript
import { singlePointer } from "cereb";
import { tapRecognizer } from "@cereb/tap";

singlePointer(element)
.pipe(tapRecognizer({ durationThreshold: 300 }))
.on((signal) => { /* ... */ });
```

## Advanced: tapEndOnly

Only emits successful taps (filters out start/cancel):

```typescript
import { singlePointer } from "cereb";
import { tapEndOnly } from "@cereb/tap";

singlePointer(element)
.pipe(tapEndOnly())
.on((signal) => {
console.log(`Tap ${signal.value.tapCount}!`);
});
```

## Advanced: createTapRecognizer

Low-level API for imperative usage or custom integrations.
The recognizer accepts any signal that satisfies `TapSourceSignal` interface.

```typescript
import { createTapRecognizer, type TapSourceSignal } from "@cereb/tap";

const recognizer = createTapRecognizer({ durationThreshold: 300 });

// Works with any source that provides the required properties
function handlePointerEvent(signal: TapSourceSignal) {
const tapEvent = recognizer.process(signal);
if (tapEvent?.value.phase === "end") {
console.log(`Tap ${tapEvent.value.tapCount}!`);
}
}
```

### TapSourceSignal Interface

```typescript
interface TapSourceSignal {
value: {
phase: "start" | "move" | "end" | "cancel";
x: number;
y: number;
pageX: number;
pageY: number;
pointerType: "touch" | "mouse" | "pen" | "unknown";
};
createdAt: number;
deviceId: string;
}
```
3 changes: 3 additions & 0 deletions docs/src/layouts/docs-layout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions docs/src/pages/stream-api/pan.astro
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const headings = [
{ depth: 2, slug: "phase-lifecycle", text: "Phase Lifecycle" },
{ depth: 2, slug: "operators", text: "Operators" },
{ depth: 2, slug: "advanced-panrecognizer", text: "Advanced: panRecognizer" },
{ depth: 2, slug: "advanced-createpanrecognizer", text: "Advanced: createPanRecognizer" },
];
---

Expand Down
1 change: 1 addition & 0 deletions docs/src/pages/stream-api/pinch.astro
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const headings = [
{ depth: 2, slug: "phase-lifecycle", text: "Phase Lifecycle" },
{ depth: 2, slug: "with-zoom-operator", text: "With Zoom Operator" },
{ depth: 2, slug: "advanced-pinchrecognizer", text: "Advanced: pinchRecognizer" },
{ depth: 2, slug: "advanced-createpinchrecognizer", text: "Advanced: createPinchRecognizer" },
];
---

Expand Down
21 changes: 21 additions & 0 deletions docs/src/pages/stream-api/tap.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
import DocsLayout from "@/layouts/docs-layout.astro";
import Content from "@/content/stream-api/tap.mdx";

const headings = [
{ depth: 2, slug: "basic-usage", text: "Basic Usage" },
{ depth: 2, slug: "signature", text: "Signature" },
{ depth: 2, slug: "options", text: "Options" },
{ depth: 2, slug: "signal-value", text: "Signal Value" },
{ depth: 2, slug: "phase-lifecycle", text: "Phase Lifecycle" },
{ depth: 2, slug: "multi-tap-chaining", text: "Multi-Tap Chaining" },
{ depth: 2, slug: "with-visual-feedback", text: "With Visual Feedback" },
{ depth: 2, slug: "advanced-taprecognizer", text: "Advanced: tapRecognizer" },
{ depth: 2, slug: "advanced-tapendonly", text: "Advanced: tapEndOnly" },
{ depth: 2, slug: "advanced-createtaprecognizer", text: "Advanced: createTapRecognizer" },
];
---

<DocsLayout title="tap" description="Tap gesture recognition with multi-tap support" headings={headings}>
<Content />
</DocsLayout>
4 changes: 0 additions & 4 deletions docs/src/styles/custom-design/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -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]! ;
}
Expand Down
Loading