yubisac 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +13 -0
- package/EXAMPLES.md +186 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/core.ts +306 -0
- package/dist/core.d.ts +82 -0
- package/dist/core.js +178 -0
- package/dist/yubisac.global.js +2 -0
- package/index.ts +1 -0
- package/native.ts +22 -0
- package/package.json +94 -0
- package/react-native.tsx +138 -0
- package/react.tsx +55 -0
- package/solid.tsx +38 -0
- package/svelte.ts +34 -0
- package/vue.ts +39 -0
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Found a bug? Please open an issue and let me know — I'll fix it on my end.
|
|
4
|
+
|
|
5
|
+
Want a feature? Please open an issue — I know there's still a lot missing, so I'd love to hear what you need.
|
|
6
|
+
|
|
7
|
+
If you'd rather fix it yourself, open an issue and send a PR — I'll usually merge it in.
|
|
8
|
+
|
|
9
|
+
## `null` vs `undefined`
|
|
10
|
+
|
|
11
|
+
Default to `undefined` for "nothing there". Use `null` only when you deliberately put it there, or
|
|
12
|
+
when the DOM/a framework hands it to you (e.g. `elementFromPoint`, React refs, `render(null)` to
|
|
13
|
+
unmount). So a `null` in this code is always on purpose.
|
package/EXAMPLES.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# yubisac examples 🫰
|
|
2
|
+
|
|
3
|
+
Copy-paste recipes. Everything is the same idea: **different inputs → one intent → your handler.** The app
|
|
4
|
+
code only ever names the intent (`inspect`, `place`, `context`), never the gesture.
|
|
5
|
+
|
|
6
|
+
- [Presets — bind without a profile](#presets)
|
|
7
|
+
- [Custom profile — your own intents](#custom-profile)
|
|
8
|
+
- [Keyboard = accessibility, for free](#keyboard)
|
|
9
|
+
- [The conflict cases it resolves](#conflict-cases)
|
|
10
|
+
- [Adapters](#adapters) — core / native · Solid · React · Vue · Svelte · React Native
|
|
11
|
+
- [CDN / plain HTML](#cdn)
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Presets
|
|
16
|
+
|
|
17
|
+
Common mappings ship built in — bind them by name, no profile:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { yubisac } from 'yubisac'
|
|
21
|
+
|
|
22
|
+
const card = yubisac(el)
|
|
23
|
+
card.clingTo('activate', open) // mouse click · touch tap · Enter/Space
|
|
24
|
+
card.clingTo('secondary-activate', inspect) // mouse double-click · touch/pen long-press
|
|
25
|
+
card.clingTo('context', showMenu) // mouse right-click · ContextMenu key
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
| Intent | touch / pen | mouse | keyboard |
|
|
29
|
+
| --- | --- | --- | --- |
|
|
30
|
+
| `activate` | tap | click | Enter / Space |
|
|
31
|
+
| `secondary-activate` | long-press | double-click | — |
|
|
32
|
+
| `context` | — ¹ | right-click | ContextMenu |
|
|
33
|
+
|
|
34
|
+
¹ `context` is a desktop notion (right-click / ContextMenu key). Touch has no right-click, so use `secondary-activate` (a long-press) for the touch secondary action.
|
|
35
|
+
|
|
36
|
+
Keyboard intents need a focusable element — give it `tabindex="0"` (and a `role`) so it can receive the key.
|
|
37
|
+
|
|
38
|
+
## Custom profile
|
|
39
|
+
|
|
40
|
+
Define your app's intent vocabulary once; the gesture names never leak past this object:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
const finger = yubisac(el, {
|
|
44
|
+
open: { touch: 'tap', mouse: 'click', keyboard: ['Enter', 'Space'] },
|
|
45
|
+
inspect: { touch: 'long-press', mouse: 'double-click' },
|
|
46
|
+
context: { touch: 'long-press', mouse: 'right-click', keyboard: 'ContextMenu' },
|
|
47
|
+
})
|
|
48
|
+
finger.clingTo('open', openItem)
|
|
49
|
+
finger.clingTo('inspect', openInspector)
|
|
50
|
+
finger.clingTo('context', showMenu)
|
|
51
|
+
|
|
52
|
+
// later
|
|
53
|
+
finger.destroy() // removes every listener
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`intent()` returns an `off()` to unbind just that one:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
const off = finger.clingTo('inspect', openInspector)
|
|
60
|
+
off()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Tune only what a person reasonably tunes:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
yubisac(el, profile, { hold: 400, tolerance: 12 }) // long-press ms · move px
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Keyboard
|
|
70
|
+
|
|
71
|
+
Add a `keyboard` key and the same intent works with no pointer at all — "make it work on tablets"
|
|
72
|
+
quietly becomes "make it work for keyboard users too":
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
yubisac(el, {
|
|
76
|
+
inspect: { touch: 'long-press', mouse: 'double-click', keyboard: 'Enter' },
|
|
77
|
+
}).clingTo('inspect', openInspector)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The handler still gets coordinates for a keyboard fire — the element's centre — so a menu can anchor sensibly.
|
|
81
|
+
|
|
82
|
+
## Conflict cases
|
|
83
|
+
|
|
84
|
+
The reason to reach for a library instead of 20 lines. Resolved once here, deterministically:
|
|
85
|
+
|
|
86
|
+
- a **single** waits out the double-click window only when a **double is actually bound** — otherwise it fires at once
|
|
87
|
+
- a **hold** is abandoned the moment the finger moves past `tolerance` (it was a drag or a scroll)
|
|
88
|
+
- `pointercancel` (the browser taking over for **scroll**) drops the whole recognition — yubisac never sets `touch-action: none`, so the page keeps scrolling
|
|
89
|
+
- the **compatibility mouse events** a browser fires *after* a touch are ignored, so nothing double-counts
|
|
90
|
+
- mouse / touch / pen interleaving on one device stay untangled — the mapping is chosen per **event** `pointerType`, never a device flag
|
|
91
|
+
- the **OS context menu** is suppressed when a right-click intent is bound, so your own menu wins
|
|
92
|
+
|
|
93
|
+
Opt a subtree out (e.g. a card with its own menu) with `data-yubisac-ignore`:
|
|
94
|
+
|
|
95
|
+
```html
|
|
96
|
+
<div data-yubisac-ignore> … its own context menu lives here … </div>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Drag lives in [goonteh](https://github.com/mrksye/goonteh); yubisac never drags. A hold cancels on movement,
|
|
100
|
+
goonteh's drag begins on movement — complementary, no coordination needed. Put both on the same element.
|
|
101
|
+
|
|
102
|
+
## Adapters
|
|
103
|
+
|
|
104
|
+
### core / native
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { yubisac } from 'yubisac'
|
|
108
|
+
const off = yubisac(el, { pick: { touch: 'long-press', mouse: 'double-click' } }).clingTo('pick', onPick)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Solid
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
import { cling, Cling } from 'yubisac/solid'
|
|
115
|
+
|
|
116
|
+
// ref helper — fits an existing element
|
|
117
|
+
<div ref={cling({ inspect: openInspector }, { profile: { inspect: { touch: 'long-press', mouse: 'double-click' } } })} />
|
|
118
|
+
|
|
119
|
+
// or the wrapper
|
|
120
|
+
<Cling on={{ 'secondary-activate': place }}>{children}</Cling>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
No provider — yubisac is element-local (unlike a shared drag context).
|
|
124
|
+
|
|
125
|
+
### React
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
import { useCling, Cling } from 'yubisac/react'
|
|
129
|
+
|
|
130
|
+
function Card() {
|
|
131
|
+
const ref = useCling({ inspect: openInspector }, { profile: { inspect: { touch: 'long-press', mouse: 'double-click' } } })
|
|
132
|
+
return <div ref={ref} />
|
|
133
|
+
}
|
|
134
|
+
// or the wrapper
|
|
135
|
+
<Cling on={{ 'secondary-activate': place }}>{children}</Cling>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Vue
|
|
139
|
+
|
|
140
|
+
```vue
|
|
141
|
+
<script setup>
|
|
142
|
+
import { vCling } from 'yubisac/vue'
|
|
143
|
+
</script>
|
|
144
|
+
<template>
|
|
145
|
+
<div v-cling="{ on: { inspect: openInspector }, profile: { inspect: { touch: 'long-press', mouse: 'double-click' } } }" />
|
|
146
|
+
</template>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Svelte
|
|
150
|
+
|
|
151
|
+
```svelte
|
|
152
|
+
<script>
|
|
153
|
+
import { cling } from 'yubisac/svelte'
|
|
154
|
+
</script>
|
|
155
|
+
<div use:cling={{ on: { inspect: openInspector }, profile: { inspect: { touch: 'long-press', mouse: 'double-click' } } }} />
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### React Native (experimental)
|
|
159
|
+
|
|
160
|
+
No DOM, so its own `PanResponder` recogniser — touch only, and only the `touch` mapping of each intent
|
|
161
|
+
applies. Spread the handlers onto a `View`, or use the `<Cling>` wrapper:
|
|
162
|
+
|
|
163
|
+
```tsx
|
|
164
|
+
import { useCling, Cling } from 'yubisac/react-native'
|
|
165
|
+
|
|
166
|
+
function Card() {
|
|
167
|
+
const handlers = useCling({ inspect: (e) => openInspector() }, { profile: { inspect: { touch: 'long-press' } } })
|
|
168
|
+
return <View {...handlers} />
|
|
169
|
+
}
|
|
170
|
+
// or
|
|
171
|
+
<Cling on={{ 'secondary-activate': place }}>{children}</Cling>
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## CDN
|
|
175
|
+
|
|
176
|
+
Zero-dep and framework-free, so one `<script>` binds intents anywhere — even a Google Apps Script
|
|
177
|
+
`HtmlService` iframe with no bundler:
|
|
178
|
+
|
|
179
|
+
```html
|
|
180
|
+
<script src="https://unpkg.com/yubisac@0.9.1"></script>
|
|
181
|
+
<script>
|
|
182
|
+
yubisac(document.getElementById('card'), {
|
|
183
|
+
inspect: { touch: 'long-press', mouse: 'double-click' },
|
|
184
|
+
}).clingTo('inspect', () => openInspector())
|
|
185
|
+
</script>
|
|
186
|
+
```
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mrksye
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# yubisac 🫰
|
|
2
|
+
|
|
3
|
+
**Touch-first intent binding.** Design for the tablet — a tap, a long-press — then let the mouse, pen and keyboard **cling to the same intent.** One code path, touch-native, desktop along for the ride.
|
|
4
|
+
|
|
5
|
+
*Pronounced "yubi-sac" — the name respells 指サック, the little cot worn on a fingertip for precise single-finger work.*
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { yubisac } from 'yubisac'
|
|
9
|
+
|
|
10
|
+
yubisac(card, {
|
|
11
|
+
inspect: { touch: 'long-press', mouse: 'double-click', pen: 'long-press', keyboard: 'Enter' },
|
|
12
|
+
}).clingTo('inspect', () => openInspector(card))
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`openInspector` fires whether you long-pressed on a tablet, double-clicked on a laptop, or pressed Enter. The app names the intent — `inspect` — never the gesture.
|
|
16
|
+
|
|
17
|
+
## A shift, not another gesture engine
|
|
18
|
+
|
|
19
|
+
The world is going touch-first — a tablet on every job site — but input libraries are still desktop-first: the click is the canonical action, and touch is bolted on. yubisac flips it. **The intent is the anchor**, and every input **clings** to it — static cling, the everyday electrostatic pull — so a touch long-press, a desktop double-click and an Enter key are the *one* `inspect`.
|
|
20
|
+
|
|
21
|
+
Hammer.js, interact.js, [@use-gesture](https://use-gesture.netlify.app) and tinygesture **recognise** gestures and hand them back as *separate* events; Pointer Events unify the device but stop at *which* device. The layer above — binding different inputs to the same intent — is the one nobody carved out. That's all yubisac is, on purpose: grow pinch/rotate/swipe and it's on Hammer's turf, out-muscled. Drag lives in its sibling [goonteh](https://github.com/mrksye/goonteh) (軍手, work gloves for gripping); yubisac (指サック, a finger cot for precise taps) never drags.
|
|
22
|
+
|
|
23
|
+
Recognition is a deliberately small set — `click` · `double-click` · `long-press` · `right-click` · `Enter`/`Space` — and stays small. The value is the deterministic conflict resolution underneath (single-vs-double wait, move cancels a hold, `pointercancel` on scroll, post-touch compatibility-mouse dedup), plus keyboard as a free accessibility layer.
|
|
24
|
+
|
|
25
|
+
## Runs where a framework can't — CDN, plain HTML
|
|
26
|
+
|
|
27
|
+
Zero dependencies, framework-free core. One `<script>` and you're clinging intents — no bundler, no `npm install` (even inside a Google Apps Script `HtmlService` page):
|
|
28
|
+
|
|
29
|
+
```html
|
|
30
|
+
<script src="https://unpkg.com/yubisac@0.9.1"></script>
|
|
31
|
+
<script>
|
|
32
|
+
yubisac(document.getElementById('card'), {
|
|
33
|
+
inspect: { touch: 'long-press', mouse: 'double-click' },
|
|
34
|
+
}).clingTo('inspect', () => openInspector())
|
|
35
|
+
</script>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Frameworks
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
npm i yubisac
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Thin adapters wrap the same core — install only the one you use (each an optional peer dependency); the core needs nothing.
|
|
45
|
+
|
|
46
|
+
| Import | Framework |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `yubisac` / `yubisac/core` | Framework-agnostic engine (vanilla TS + DOM) |
|
|
49
|
+
| `yubisac/native` | Vanilla DOM sugar (`cling`) |
|
|
50
|
+
| `yubisac/solid` | SolidJS — `cling`, `<Cling>` |
|
|
51
|
+
| `yubisac/react` | React ≥ 18 — `useCling`, `<Cling>` |
|
|
52
|
+
| `yubisac/vue` | Vue ≥ 3.2 — `v-cling` directive |
|
|
53
|
+
| `yubisac/svelte` | Svelte ≥ 4 — `use:cling` action |
|
|
54
|
+
| `yubisac/react-native` | React Native (**experimental** — its own PanResponder recogniser, touch only) |
|
|
55
|
+
|
|
56
|
+
> 📖 **[EXAMPLES.md](./EXAMPLES.md)** — presets, custom profiles, keyboard/accessibility, the conflict-resolution cases, and per-adapter recipes.
|
|
57
|
+
|
|
58
|
+
## API (core)
|
|
59
|
+
|
|
60
|
+
`yubisac(el, profile?, config?)` → `{ clingTo(intent, handler) → off(), destroy() }`.
|
|
61
|
+
|
|
62
|
+
- `profile` — `{ [intent]: { touch?, mouse?, pen?, keyboard? } }`; built-in `PRESETS` (`activate`, `secondary-activate`, `context`) fill in known names.
|
|
63
|
+
- gestures — `click`/`tap`, `double-click`/`double-tap`, `long-press`, `right-click`/`context-menu`.
|
|
64
|
+
- `config` — two knobs only: `{ hold=500, tolerance=8 }`.
|
|
65
|
+
- handler receives `{ intent, pointerType, clientX, clientY, currentTarget, originalEvent }`.
|
|
66
|
+
|
|
67
|
+
## Status
|
|
68
|
+
|
|
69
|
+
Born on a construction-site scheduling app that had to work on a foreman's tablet (touch) and an office laptop (mouse) from one code path. Recognition stays small; the investment is the binding and the conflict resolution, not more gestures. The core is pinned by real-browser tests (Playwright / Chromium) — `bun run test` — covering single-vs-double timing, long-press vs move/scroll, `pointercancel`, compatibility-mouse dedup, keyboard, and teardown.
|
|
70
|
+
|
|
71
|
+
## Contributing · License
|
|
72
|
+
|
|
73
|
+
Found a bug? Please **open an issue first** — see [CONTRIBUTING.md](./CONTRIBUTING.md). MIT © mrksye.
|
|
74
|
+
|
|
75
|
+
> *One intent, every finger.*
|
package/core.ts
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* yubisac core — a framework-agnostic **touch-first intent binding** engine.
|
|
3
|
+
*
|
|
4
|
+
* Not a gesture library. Hammer.js, interact.js, @use-gesture and tinygesture all recognise gestures
|
|
5
|
+
* (tap / double-tap / long-press / pinch …) and hand them to you as *separate* events. Pointer Events
|
|
6
|
+
* unify the input device but stop at "which device". The layer nobody owns is the one above: **every
|
|
7
|
+
* input clinging to the same operation intent** — a mouse double-click, a touch long-press and an Enter
|
|
8
|
+
* key are the one `inspect`. The app speaks in intents (`inspect`, `context`, `activate`); raw event
|
|
9
|
+
* names never leak into its code. Design for the tablet first, and let the desktop cling on.
|
|
10
|
+
*
|
|
11
|
+
* That is all yubisac does, and deliberately all it does: bind different inputs to one intent, and resolve
|
|
12
|
+
* the conflicts between them deterministically (single-vs-double wait, move cancels a hold, a touch's
|
|
13
|
+
* trailing compatibility-mouse events, scroll takeover). Recognition stays a tight set on purpose — the
|
|
14
|
+
* moment it grows into a gesture powerhouse it loses to the incumbents on their turf.
|
|
15
|
+
*
|
|
16
|
+
* Basis is the **per-event `pointerType`**, never a device flag: the same machine sends mouse, touch and
|
|
17
|
+
* pen events interchangeably, so the mapping is chosen per event, not per device.
|
|
18
|
+
*
|
|
19
|
+
* The name respells 指サック (yubi-sakku), the little cot worn on a fingertip for precise single-finger
|
|
20
|
+
* work — where goonteh (軍手, work gloves) is the whole hand gripping and dragging, yubisac is one
|
|
21
|
+
* fingertip tapping. Two hand-worn tools, one drag, one touch.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Which pointing device an event came from — read per event, not per device. */
|
|
25
|
+
export type PointerKind = 'mouse' | 'touch' | 'pen'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A recognisable gesture token. Synonyms exist so a mapping reads naturally per device
|
|
29
|
+
* (`mouse: 'double-click'` vs `touch: 'double-tap'`); they collapse to the same recogniser.
|
|
30
|
+
*/
|
|
31
|
+
export type Gesture = 'click' | 'tap' | 'double-click' | 'double-tap' | 'long-press' | 'context-menu' | 'right-click'
|
|
32
|
+
|
|
33
|
+
/** A keyboard key that fires an intent — `e.key` values (`'Enter'`, `'ContextMenu'`); `'Space'` is accepted for `' '`. */
|
|
34
|
+
export type Key = string
|
|
35
|
+
|
|
36
|
+
/** How one intent is reached, per input. Omit a device to not bind that device for this intent. */
|
|
37
|
+
export type IntentMapping = Partial<Record<PointerKind, Gesture>> & { keyboard?: Key | Key[] }
|
|
38
|
+
|
|
39
|
+
/** A named set of intent → per-input mappings. The app's intent vocabulary lives here, once. */
|
|
40
|
+
export type Profile = Record<string, IntentMapping>
|
|
41
|
+
|
|
42
|
+
/** What a bound handler receives when its intent fires. `clientX/clientY` is the pointer (or the element centre for keyboard). */
|
|
43
|
+
export type IntentEvent = {
|
|
44
|
+
intent: string
|
|
45
|
+
pointerType: PointerKind | 'keyboard'
|
|
46
|
+
clientX: number
|
|
47
|
+
clientY: number
|
|
48
|
+
currentTarget: HTMLElement
|
|
49
|
+
/** The raw event. A right-click intent carries a `MouseEvent` (contextmenu), a keyboard intent a `KeyboardEvent`. */
|
|
50
|
+
originalEvent: PointerEvent | MouseEvent | KeyboardEvent
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type IntentHandler = (e: IntentEvent) => void
|
|
54
|
+
|
|
55
|
+
/** A binding handle for one element. Cling handlers to intents; `destroy()` removes every listener. */
|
|
56
|
+
export type Finger = {
|
|
57
|
+
/** Cling a handler to a named intent (from this element's profile, else a built-in preset). Returns an off(). */
|
|
58
|
+
clingTo(intent: string, handler: IntentHandler): () => void
|
|
59
|
+
destroy(): void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The only two knobs, on purpose. Everything else (the double window, the post-touch compatibility-mouse
|
|
64
|
+
* window) is a browser-timing fact, not an app preference — kept as internal constants so the surface stays
|
|
65
|
+
* a finger cot, not a powered suit.
|
|
66
|
+
*/
|
|
67
|
+
export type YubisacConfig = {
|
|
68
|
+
/** Hold this long (ms) without moving to fire a long-press. Default 500. */
|
|
69
|
+
hold?: number
|
|
70
|
+
/** Move more than this (px) and a tap/hold is abandoned (it's a drag or scroll). Default 8. */
|
|
71
|
+
tolerance?: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A second press within this long (ms) resolves the pair as a double. */
|
|
75
|
+
const DOUBLE_MS = 280
|
|
76
|
+
/** After a touch, ignore mouse events for this long — the browser's compatibility mouse events. */
|
|
77
|
+
const COMPAT_MOUSE_MS = 500
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Built-in intent presets — a named default mapping you can bind straight away, or override per element
|
|
81
|
+
* by passing your own profile. Keyboard bindings make these an accessibility layer, not just touch support.
|
|
82
|
+
*/
|
|
83
|
+
export const PRESETS: Profile = {
|
|
84
|
+
activate: { touch: 'tap', pen: 'tap', mouse: 'click', keyboard: ['Enter', 'Space'] },
|
|
85
|
+
'secondary-activate': { touch: 'long-press', pen: 'long-press', mouse: 'double-click' },
|
|
86
|
+
// `context` is a desktop notion (a right-click / the ContextMenu key). Touch has no right-click, so it
|
|
87
|
+
// carries no touch mapping — reach for `secondary-activate` (a long-press) for the touch secondary action.
|
|
88
|
+
context: { mouse: 'right-click', keyboard: 'ContextMenu' },
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The recogniser category a gesture token collapses to — the engine reasons in these, not the tokens. */
|
|
92
|
+
type Recogniser = 'single' | 'double' | 'hold' | 'context'
|
|
93
|
+
|
|
94
|
+
const RECOGNISER: Record<Gesture, Recogniser> = {
|
|
95
|
+
click: 'single',
|
|
96
|
+
tap: 'single',
|
|
97
|
+
'double-click': 'double',
|
|
98
|
+
'double-tap': 'double',
|
|
99
|
+
'long-press': 'hold',
|
|
100
|
+
'context-menu': 'context',
|
|
101
|
+
'right-click': 'context',
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Normalise a keyboard token to a comparable `e.key` (`'Space'` → `' '`), case-insensitively. */
|
|
105
|
+
const normalizeKey = (k: Key): string => (k.toLowerCase() === 'space' ? ' ' : k.toLowerCase())
|
|
106
|
+
|
|
107
|
+
type Binding = { intent: string; mapping: IntentMapping; handler: IntentHandler }
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Attach intent binding to one element. `profile` names the intents this element understands (falling back
|
|
111
|
+
* to {@link PRESETS} for built-in names); handlers are clung on later by `clingTo(intent, handler)`.
|
|
112
|
+
*
|
|
113
|
+
* One recogniser runs per element: pointer/keyboard events are watched once, resolved into a category
|
|
114
|
+
* (single / double / hold / context), and dispatched to whichever bound intents map that category for the
|
|
115
|
+
* event's `pointerType`. The app never sees the gesture — only its intent.
|
|
116
|
+
*
|
|
117
|
+
* Keyboard intents need a focusable element (a `keydown` only reaches `el` when it holds focus) — give it
|
|
118
|
+
* `tabindex="0"` and a `role`. Timings compare `event.timeStamp` across event types (all `DOMHighResTimeStamp`).
|
|
119
|
+
*/
|
|
120
|
+
export function yubisac(el: HTMLElement, profile: Profile = {}, config: YubisacConfig = {}): Finger {
|
|
121
|
+
const holdMs = config.hold ?? 500
|
|
122
|
+
const moveTolerance = config.tolerance ?? 8
|
|
123
|
+
|
|
124
|
+
const bindings = new Set<Binding>()
|
|
125
|
+
let listening = false
|
|
126
|
+
// Timestamp of the most recent touch activity. −Infinity until a touch happens, so a mouse event is never
|
|
127
|
+
// mistaken for a touch's compatibility event before any touch (the guard below would otherwise swallow
|
|
128
|
+
// early mouse events, whose timeStamp is small). Updated at both the start and the END of a touch.
|
|
129
|
+
let lastTouchAt = -Infinity
|
|
130
|
+
|
|
131
|
+
const mappingOf = (name: string): IntentMapping => {
|
|
132
|
+
const m = profile[name] ?? PRESETS[name]
|
|
133
|
+
if (!m) throw new Error(`yubisac: unknown intent "${name}" (not in profile or presets)`)
|
|
134
|
+
return m
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Does any bound intent want this recogniser category on this device? */
|
|
138
|
+
const wants = (pointerType: PointerKind, category: Recogniser): boolean => {
|
|
139
|
+
for (const b of bindings) {
|
|
140
|
+
const g = b.mapping[pointerType]
|
|
141
|
+
if (g && RECOGNISER[g] === category) return true
|
|
142
|
+
}
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const fire = (category: Recogniser, pointerType: PointerKind, x: number, y: number, ev: PointerEvent | MouseEvent): void => {
|
|
147
|
+
for (const b of [...bindings]) {
|
|
148
|
+
// snapshot: a handler may destroy() / unbind mid-iteration
|
|
149
|
+
const g = b.mapping[pointerType]
|
|
150
|
+
if (g && RECOGNISER[g] === category && bindings.has(b)) b.handler({ intent: b.intent, pointerType, clientX: x, clientY: y, currentTarget: el, originalEvent: ev })
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const fireKey = (name: string, handler: IntentHandler, ev: KeyboardEvent): void => {
|
|
155
|
+
const r = el.getBoundingClientRect()
|
|
156
|
+
handler({ intent: name, pointerType: 'keyboard', clientX: r.left + r.width / 2, clientY: r.top + r.height / 2, currentTarget: el, originalEvent: ev })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A single tap can only be committed once we know it isn't the first half of a double, so it waits
|
|
160
|
+
// out DOUBLE_MS — but only when a double is actually bound for that device (otherwise it fires at once).
|
|
161
|
+
let pendingSingle: { timer: ReturnType<typeof setTimeout>; x: number; y: number; ev: PointerEvent; pointerType: PointerKind } | undefined
|
|
162
|
+
const clearPending = (): void => {
|
|
163
|
+
if (pendingSingle) clearTimeout(pendingSingle.timer)
|
|
164
|
+
pendingSingle = undefined
|
|
165
|
+
}
|
|
166
|
+
/** Cleanup for a press still in flight (window listeners + the hold timer). Cleared when it settles. */
|
|
167
|
+
let activePress: (() => void) | undefined
|
|
168
|
+
|
|
169
|
+
const ignored = (e: Event): boolean => (e.target as Element | null)?.closest('[data-yubisac-ignore]') != null
|
|
170
|
+
|
|
171
|
+
const onPointerDown = (e: PointerEvent): void => {
|
|
172
|
+
if (!e.isPrimary) return // ignore secondary touches of a multi-touch gesture
|
|
173
|
+
if (ignored(e)) return // a descendant opting out (e.g. a card with its own menu) keeps its own handling
|
|
174
|
+
if (e.pointerType === 'touch') lastTouchAt = e.timeStamp
|
|
175
|
+
// The browser fires compatibility mouse events after a touch; ignore them so we don't double-count.
|
|
176
|
+
if (e.pointerType === 'mouse' && e.timeStamp - lastTouchAt < COMPAT_MOUSE_MS) return
|
|
177
|
+
if (e.pointerType === 'mouse' && e.button !== 0) return // right/middle mouse come through `contextmenu`, not here
|
|
178
|
+
const pointerType = e.pointerType as PointerKind
|
|
179
|
+
const id = e.pointerId
|
|
180
|
+
const sx = e.clientX
|
|
181
|
+
const sy = e.clientY
|
|
182
|
+
let moved = false
|
|
183
|
+
let held = false
|
|
184
|
+
// Touch/pen: the browser fires its own long-press context menu, which pops a native menu over the app's.
|
|
185
|
+
// onContextMenu below deliberately skips the touch-synthesised contextmenu (to avoid double-firing an
|
|
186
|
+
// intent), so it would otherwise slip through — suppress the native menu for the life of this press.
|
|
187
|
+
// isTrusted only; mouse right-click comes through onContextMenu (button != 0 returned above), untouched.
|
|
188
|
+
const suppressNativeContextMenu = pointerType !== 'mouse' ? (ev: Event): void => { if (ev.isTrusted) ev.preventDefault() } : undefined
|
|
189
|
+
|
|
190
|
+
// A second press while a single is pending, close in time and place, resolves the pair as a double.
|
|
191
|
+
if (pendingSingle && pendingSingle.pointerType === pointerType && Math.hypot(sx - pendingSingle.x, sy - pendingSingle.y) < moveTolerance * 3) {
|
|
192
|
+
clearPending()
|
|
193
|
+
held = true // this down belongs to the double; its up must not start another single
|
|
194
|
+
fire('double', pointerType, sx, sy, e)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const holdTimer = wants(pointerType, 'hold')
|
|
198
|
+
? setTimeout(() => {
|
|
199
|
+
if (moved || held) return // moved = drag/scroll; held = this down already completed a double
|
|
200
|
+
held = true // consumes this pointer: its up won't also fire a tap
|
|
201
|
+
fire('hold', pointerType, sx, sy, e)
|
|
202
|
+
}, holdMs)
|
|
203
|
+
: undefined
|
|
204
|
+
|
|
205
|
+
const teardown = (): void => {
|
|
206
|
+
if (holdTimer) clearTimeout(holdTimer)
|
|
207
|
+
// Mark the END of a touch too — the browser's compatibility mouse events arrive after the touch lifts,
|
|
208
|
+
// so the dedup window must be measured from here, not just from the down.
|
|
209
|
+
if (pointerType === 'touch') lastTouchAt = performance.now()
|
|
210
|
+
window.removeEventListener('pointermove', move)
|
|
211
|
+
window.removeEventListener('pointerup', up)
|
|
212
|
+
window.removeEventListener('pointercancel', cancel)
|
|
213
|
+
if (suppressNativeContextMenu) window.removeEventListener('contextmenu', suppressNativeContextMenu)
|
|
214
|
+
activePress = undefined
|
|
215
|
+
}
|
|
216
|
+
const move = (ev: PointerEvent): void => {
|
|
217
|
+
if (ev.pointerId !== id) return
|
|
218
|
+
if (Math.hypot(ev.clientX - sx, ev.clientY - sy) < moveTolerance) return
|
|
219
|
+
moved = true // a drag or a scroll — no tap, no hold
|
|
220
|
+
teardown()
|
|
221
|
+
}
|
|
222
|
+
const up = (ev: PointerEvent): void => {
|
|
223
|
+
if (ev.pointerId !== id) return
|
|
224
|
+
teardown()
|
|
225
|
+
if (moved || held) return // moved = drag/scroll; held = a double or long-press already spoke
|
|
226
|
+
if (wants(pointerType, 'double')) {
|
|
227
|
+
clearPending() // a stray earlier single that never doubled — don't leave its timer running
|
|
228
|
+
const timer = setTimeout(() => {
|
|
229
|
+
if (pendingSingle?.timer === timer) pendingSingle = undefined // only null my own entry
|
|
230
|
+
fire('single', pointerType, ev.clientX, ev.clientY, ev)
|
|
231
|
+
}, DOUBLE_MS)
|
|
232
|
+
pendingSingle = { timer, x: ev.clientX, y: ev.clientY, ev, pointerType }
|
|
233
|
+
} else {
|
|
234
|
+
fire('single', pointerType, ev.clientX, ev.clientY, ev)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const cancel = (ev: PointerEvent): void => {
|
|
238
|
+
if (ev.pointerId !== id) return
|
|
239
|
+
teardown() // scroll takeover / gesture aborted — drop the whole recognition
|
|
240
|
+
}
|
|
241
|
+
activePress = teardown
|
|
242
|
+
if (suppressNativeContextMenu) window.addEventListener('contextmenu', suppressNativeContextMenu)
|
|
243
|
+
// passive:false on move so a would-be hold that becomes a drag can still let the page scroll — we never
|
|
244
|
+
// preventDefault here (unlike goonteh), so touch scrolling keeps working; a real scroll fires pointercancel.
|
|
245
|
+
window.addEventListener('pointermove', move, { passive: false })
|
|
246
|
+
window.addEventListener('pointerup', up)
|
|
247
|
+
window.addEventListener('pointercancel', cancel)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const onContextMenu = (e: MouseEvent): void => {
|
|
251
|
+
if (ignored(e)) return
|
|
252
|
+
if (e.timeStamp - lastTouchAt < COMPAT_MOUSE_MS) return // a touch long-press can synthesise contextmenu; skip it here
|
|
253
|
+
if (!wants('mouse', 'context')) return
|
|
254
|
+
e.preventDefault() // a right-click intent is bound → the app opens its own menu, not the OS one
|
|
255
|
+
fire('context', 'mouse', e.clientX, e.clientY, e)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const onKeyDown = (e: KeyboardEvent): void => {
|
|
259
|
+
if (e.repeat) return // a held key auto-repeats; fire once, not a machine-gun
|
|
260
|
+
if (ignored(e)) return // a focused descendant opting out keeps its own handling
|
|
261
|
+
if (e.ctrlKey || e.metaKey || e.altKey) return // a shortcut, not a plain activation
|
|
262
|
+
const key = e.key.toLowerCase()
|
|
263
|
+
for (const b of [...bindings]) {
|
|
264
|
+
const keys = b.mapping.keyboard
|
|
265
|
+
if (!keys) continue
|
|
266
|
+
const list = Array.isArray(keys) ? keys : [keys]
|
|
267
|
+
if (list.some((k) => normalizeKey(k) === key) && bindings.has(b)) {
|
|
268
|
+
if (key === ' ') e.preventDefault() // Space would scroll the page
|
|
269
|
+
fireKey(b.intent, b.handler, e)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const startListening = (): void => {
|
|
275
|
+
if (listening) return
|
|
276
|
+
listening = true
|
|
277
|
+
el.addEventListener('pointerdown', onPointerDown)
|
|
278
|
+
el.addEventListener('contextmenu', onContextMenu)
|
|
279
|
+
el.addEventListener('keydown', onKeyDown)
|
|
280
|
+
}
|
|
281
|
+
const stopListening = (): void => {
|
|
282
|
+
if (!listening) return
|
|
283
|
+
listening = false
|
|
284
|
+
el.removeEventListener('pointerdown', onPointerDown)
|
|
285
|
+
el.removeEventListener('contextmenu', onContextMenu)
|
|
286
|
+
el.removeEventListener('keydown', onKeyDown)
|
|
287
|
+
activePress?.() // a press still in flight: drop its window listeners and hold timer
|
|
288
|
+
clearPending()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
clingTo(name, handler) {
|
|
293
|
+
const binding: Binding = { intent: name, mapping: mappingOf(name), handler }
|
|
294
|
+
bindings.add(binding)
|
|
295
|
+
startListening()
|
|
296
|
+
return () => {
|
|
297
|
+
bindings.delete(binding)
|
|
298
|
+
if (bindings.size === 0) stopListening()
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
destroy() {
|
|
302
|
+
bindings.clear()
|
|
303
|
+
stopListening()
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
}
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* yubisac core — a framework-agnostic **touch-first intent binding** engine.
|
|
3
|
+
*
|
|
4
|
+
* Not a gesture library. Hammer.js, interact.js, @use-gesture and tinygesture all recognise gestures
|
|
5
|
+
* (tap / double-tap / long-press / pinch …) and hand them to you as *separate* events. Pointer Events
|
|
6
|
+
* unify the input device but stop at "which device". The layer nobody owns is the one above: **every
|
|
7
|
+
* input clinging to the same operation intent** — a mouse double-click, a touch long-press and an Enter
|
|
8
|
+
* key are the one `inspect`. The app speaks in intents (`inspect`, `context`, `activate`); raw event
|
|
9
|
+
* names never leak into its code. Design for the tablet first, and let the desktop cling on.
|
|
10
|
+
*
|
|
11
|
+
* That is all yubisac does, and deliberately all it does: bind different inputs to one intent, and resolve
|
|
12
|
+
* the conflicts between them deterministically (single-vs-double wait, move cancels a hold, a touch's
|
|
13
|
+
* trailing compatibility-mouse events, scroll takeover). Recognition stays a tight set on purpose — the
|
|
14
|
+
* moment it grows into a gesture powerhouse it loses to the incumbents on their turf.
|
|
15
|
+
*
|
|
16
|
+
* Basis is the **per-event `pointerType`**, never a device flag: the same machine sends mouse, touch and
|
|
17
|
+
* pen events interchangeably, so the mapping is chosen per event, not per device.
|
|
18
|
+
*
|
|
19
|
+
* The name respells 指サック (yubi-sakku), the little cot worn on a fingertip for precise single-finger
|
|
20
|
+
* work — where goonteh (軍手, work gloves) is the whole hand gripping and dragging, yubisac is one
|
|
21
|
+
* fingertip tapping. Two hand-worn tools, one drag, one touch.
|
|
22
|
+
*/
|
|
23
|
+
/** Which pointing device an event came from — read per event, not per device. */
|
|
24
|
+
export type PointerKind = 'mouse' | 'touch' | 'pen';
|
|
25
|
+
/**
|
|
26
|
+
* A recognisable gesture token. Synonyms exist so a mapping reads naturally per device
|
|
27
|
+
* (`mouse: 'double-click'` vs `touch: 'double-tap'`); they collapse to the same recogniser.
|
|
28
|
+
*/
|
|
29
|
+
export type Gesture = 'click' | 'tap' | 'double-click' | 'double-tap' | 'long-press' | 'context-menu' | 'right-click';
|
|
30
|
+
/** A keyboard key that fires an intent — `e.key` values (`'Enter'`, `'ContextMenu'`); `'Space'` is accepted for `' '`. */
|
|
31
|
+
export type Key = string;
|
|
32
|
+
/** How one intent is reached, per input. Omit a device to not bind that device for this intent. */
|
|
33
|
+
export type IntentMapping = Partial<Record<PointerKind, Gesture>> & {
|
|
34
|
+
keyboard?: Key | Key[];
|
|
35
|
+
};
|
|
36
|
+
/** A named set of intent → per-input mappings. The app's intent vocabulary lives here, once. */
|
|
37
|
+
export type Profile = Record<string, IntentMapping>;
|
|
38
|
+
/** What a bound handler receives when its intent fires. `clientX/clientY` is the pointer (or the element centre for keyboard). */
|
|
39
|
+
export type IntentEvent = {
|
|
40
|
+
intent: string;
|
|
41
|
+
pointerType: PointerKind | 'keyboard';
|
|
42
|
+
clientX: number;
|
|
43
|
+
clientY: number;
|
|
44
|
+
currentTarget: HTMLElement;
|
|
45
|
+
/** The raw event. A right-click intent carries a `MouseEvent` (contextmenu), a keyboard intent a `KeyboardEvent`. */
|
|
46
|
+
originalEvent: PointerEvent | MouseEvent | KeyboardEvent;
|
|
47
|
+
};
|
|
48
|
+
export type IntentHandler = (e: IntentEvent) => void;
|
|
49
|
+
/** A binding handle for one element. Cling handlers to intents; `destroy()` removes every listener. */
|
|
50
|
+
export type Finger = {
|
|
51
|
+
/** Cling a handler to a named intent (from this element's profile, else a built-in preset). Returns an off(). */
|
|
52
|
+
clingTo(intent: string, handler: IntentHandler): () => void;
|
|
53
|
+
destroy(): void;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* The only two knobs, on purpose. Everything else (the double window, the post-touch compatibility-mouse
|
|
57
|
+
* window) is a browser-timing fact, not an app preference — kept as internal constants so the surface stays
|
|
58
|
+
* a finger cot, not a powered suit.
|
|
59
|
+
*/
|
|
60
|
+
export type YubisacConfig = {
|
|
61
|
+
/** Hold this long (ms) without moving to fire a long-press. Default 500. */
|
|
62
|
+
hold?: number;
|
|
63
|
+
/** Move more than this (px) and a tap/hold is abandoned (it's a drag or scroll). Default 8. */
|
|
64
|
+
tolerance?: number;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Built-in intent presets — a named default mapping you can bind straight away, or override per element
|
|
68
|
+
* by passing your own profile. Keyboard bindings make these an accessibility layer, not just touch support.
|
|
69
|
+
*/
|
|
70
|
+
export declare const PRESETS: Profile;
|
|
71
|
+
/**
|
|
72
|
+
* Attach intent binding to one element. `profile` names the intents this element understands (falling back
|
|
73
|
+
* to {@link PRESETS} for built-in names); handlers are clung on later by `clingTo(intent, handler)`.
|
|
74
|
+
*
|
|
75
|
+
* One recogniser runs per element: pointer/keyboard events are watched once, resolved into a category
|
|
76
|
+
* (single / double / hold / context), and dispatched to whichever bound intents map that category for the
|
|
77
|
+
* event's `pointerType`. The app never sees the gesture — only its intent.
|
|
78
|
+
*
|
|
79
|
+
* Keyboard intents need a focusable element (a `keydown` only reaches `el` when it holds focus) — give it
|
|
80
|
+
* `tabindex="0"` and a `role`. Timings compare `event.timeStamp` across event types (all `DOMHighResTimeStamp`).
|
|
81
|
+
*/
|
|
82
|
+
export declare function yubisac(el: HTMLElement, profile?: Profile, config?: YubisacConfig): Finger;
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// core.ts
|
|
2
|
+
var DOUBLE_MS = 280;
|
|
3
|
+
var COMPAT_MOUSE_MS = 500;
|
|
4
|
+
var PRESETS = {
|
|
5
|
+
activate: { touch: "tap", pen: "tap", mouse: "click", keyboard: ["Enter", "Space"] },
|
|
6
|
+
"secondary-activate": { touch: "long-press", pen: "long-press", mouse: "double-click" },
|
|
7
|
+
// `context` is a desktop notion (a right-click / the ContextMenu key). Touch has no right-click, so it
|
|
8
|
+
// carries no touch mapping — reach for `secondary-activate` (a long-press) for the touch secondary action.
|
|
9
|
+
context: { mouse: "right-click", keyboard: "ContextMenu" }
|
|
10
|
+
};
|
|
11
|
+
var RECOGNISER = {
|
|
12
|
+
click: "single",
|
|
13
|
+
tap: "single",
|
|
14
|
+
"double-click": "double",
|
|
15
|
+
"double-tap": "double",
|
|
16
|
+
"long-press": "hold",
|
|
17
|
+
"context-menu": "context",
|
|
18
|
+
"right-click": "context"
|
|
19
|
+
};
|
|
20
|
+
var normalizeKey = (k) => k.toLowerCase() === "space" ? " " : k.toLowerCase();
|
|
21
|
+
function yubisac(el, profile = {}, config = {}) {
|
|
22
|
+
const holdMs = config.hold ?? 500;
|
|
23
|
+
const moveTolerance = config.tolerance ?? 8;
|
|
24
|
+
const bindings = /* @__PURE__ */ new Set();
|
|
25
|
+
let listening = false;
|
|
26
|
+
let lastTouchAt = -Infinity;
|
|
27
|
+
const mappingOf = (name) => {
|
|
28
|
+
const m = profile[name] ?? PRESETS[name];
|
|
29
|
+
if (!m) throw new Error(`yubisac: unknown intent "${name}" (not in profile or presets)`);
|
|
30
|
+
return m;
|
|
31
|
+
};
|
|
32
|
+
const wants = (pointerType, category) => {
|
|
33
|
+
for (const b of bindings) {
|
|
34
|
+
const g = b.mapping[pointerType];
|
|
35
|
+
if (g && RECOGNISER[g] === category) return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
};
|
|
39
|
+
const fire = (category, pointerType, x, y, ev) => {
|
|
40
|
+
for (const b of [...bindings]) {
|
|
41
|
+
const g = b.mapping[pointerType];
|
|
42
|
+
if (g && RECOGNISER[g] === category && bindings.has(b)) b.handler({ intent: b.intent, pointerType, clientX: x, clientY: y, currentTarget: el, originalEvent: ev });
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const fireKey = (name, handler, ev) => {
|
|
46
|
+
const r = el.getBoundingClientRect();
|
|
47
|
+
handler({ intent: name, pointerType: "keyboard", clientX: r.left + r.width / 2, clientY: r.top + r.height / 2, currentTarget: el, originalEvent: ev });
|
|
48
|
+
};
|
|
49
|
+
let pendingSingle;
|
|
50
|
+
const clearPending = () => {
|
|
51
|
+
if (pendingSingle) clearTimeout(pendingSingle.timer);
|
|
52
|
+
pendingSingle = void 0;
|
|
53
|
+
};
|
|
54
|
+
let activePress;
|
|
55
|
+
const ignored = (e) => e.target?.closest("[data-yubisac-ignore]") != null;
|
|
56
|
+
const onPointerDown = (e) => {
|
|
57
|
+
if (!e.isPrimary) return;
|
|
58
|
+
if (ignored(e)) return;
|
|
59
|
+
if (e.pointerType === "touch") lastTouchAt = e.timeStamp;
|
|
60
|
+
if (e.pointerType === "mouse" && e.timeStamp - lastTouchAt < COMPAT_MOUSE_MS) return;
|
|
61
|
+
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
62
|
+
const pointerType = e.pointerType;
|
|
63
|
+
const id = e.pointerId;
|
|
64
|
+
const sx = e.clientX;
|
|
65
|
+
const sy = e.clientY;
|
|
66
|
+
let moved = false;
|
|
67
|
+
let held = false;
|
|
68
|
+
const suppressNativeContextMenu = pointerType !== "mouse" ? (ev) => {
|
|
69
|
+
if (ev.isTrusted) ev.preventDefault();
|
|
70
|
+
} : void 0;
|
|
71
|
+
if (pendingSingle && pendingSingle.pointerType === pointerType && Math.hypot(sx - pendingSingle.x, sy - pendingSingle.y) < moveTolerance * 3) {
|
|
72
|
+
clearPending();
|
|
73
|
+
held = true;
|
|
74
|
+
fire("double", pointerType, sx, sy, e);
|
|
75
|
+
}
|
|
76
|
+
const holdTimer = wants(pointerType, "hold") ? setTimeout(() => {
|
|
77
|
+
if (moved || held) return;
|
|
78
|
+
held = true;
|
|
79
|
+
fire("hold", pointerType, sx, sy, e);
|
|
80
|
+
}, holdMs) : void 0;
|
|
81
|
+
const teardown = () => {
|
|
82
|
+
if (holdTimer) clearTimeout(holdTimer);
|
|
83
|
+
if (pointerType === "touch") lastTouchAt = performance.now();
|
|
84
|
+
window.removeEventListener("pointermove", move);
|
|
85
|
+
window.removeEventListener("pointerup", up);
|
|
86
|
+
window.removeEventListener("pointercancel", cancel);
|
|
87
|
+
if (suppressNativeContextMenu) window.removeEventListener("contextmenu", suppressNativeContextMenu);
|
|
88
|
+
activePress = void 0;
|
|
89
|
+
};
|
|
90
|
+
const move = (ev) => {
|
|
91
|
+
if (ev.pointerId !== id) return;
|
|
92
|
+
if (Math.hypot(ev.clientX - sx, ev.clientY - sy) < moveTolerance) return;
|
|
93
|
+
moved = true;
|
|
94
|
+
teardown();
|
|
95
|
+
};
|
|
96
|
+
const up = (ev) => {
|
|
97
|
+
if (ev.pointerId !== id) return;
|
|
98
|
+
teardown();
|
|
99
|
+
if (moved || held) return;
|
|
100
|
+
if (wants(pointerType, "double")) {
|
|
101
|
+
clearPending();
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
if (pendingSingle?.timer === timer) pendingSingle = void 0;
|
|
104
|
+
fire("single", pointerType, ev.clientX, ev.clientY, ev);
|
|
105
|
+
}, DOUBLE_MS);
|
|
106
|
+
pendingSingle = { timer, x: ev.clientX, y: ev.clientY, ev, pointerType };
|
|
107
|
+
} else {
|
|
108
|
+
fire("single", pointerType, ev.clientX, ev.clientY, ev);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const cancel = (ev) => {
|
|
112
|
+
if (ev.pointerId !== id) return;
|
|
113
|
+
teardown();
|
|
114
|
+
};
|
|
115
|
+
activePress = teardown;
|
|
116
|
+
if (suppressNativeContextMenu) window.addEventListener("contextmenu", suppressNativeContextMenu);
|
|
117
|
+
window.addEventListener("pointermove", move, { passive: false });
|
|
118
|
+
window.addEventListener("pointerup", up);
|
|
119
|
+
window.addEventListener("pointercancel", cancel);
|
|
120
|
+
};
|
|
121
|
+
const onContextMenu = (e) => {
|
|
122
|
+
if (ignored(e)) return;
|
|
123
|
+
if (e.timeStamp - lastTouchAt < COMPAT_MOUSE_MS) return;
|
|
124
|
+
if (!wants("mouse", "context")) return;
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
fire("context", "mouse", e.clientX, e.clientY, e);
|
|
127
|
+
};
|
|
128
|
+
const onKeyDown = (e) => {
|
|
129
|
+
if (e.repeat) return;
|
|
130
|
+
if (ignored(e)) return;
|
|
131
|
+
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
|
132
|
+
const key = e.key.toLowerCase();
|
|
133
|
+
for (const b of [...bindings]) {
|
|
134
|
+
const keys = b.mapping.keyboard;
|
|
135
|
+
if (!keys) continue;
|
|
136
|
+
const list = Array.isArray(keys) ? keys : [keys];
|
|
137
|
+
if (list.some((k) => normalizeKey(k) === key) && bindings.has(b)) {
|
|
138
|
+
if (key === " ") e.preventDefault();
|
|
139
|
+
fireKey(b.intent, b.handler, e);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
const startListening = () => {
|
|
144
|
+
if (listening) return;
|
|
145
|
+
listening = true;
|
|
146
|
+
el.addEventListener("pointerdown", onPointerDown);
|
|
147
|
+
el.addEventListener("contextmenu", onContextMenu);
|
|
148
|
+
el.addEventListener("keydown", onKeyDown);
|
|
149
|
+
};
|
|
150
|
+
const stopListening = () => {
|
|
151
|
+
if (!listening) return;
|
|
152
|
+
listening = false;
|
|
153
|
+
el.removeEventListener("pointerdown", onPointerDown);
|
|
154
|
+
el.removeEventListener("contextmenu", onContextMenu);
|
|
155
|
+
el.removeEventListener("keydown", onKeyDown);
|
|
156
|
+
activePress?.();
|
|
157
|
+
clearPending();
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
clingTo(name, handler) {
|
|
161
|
+
const binding = { intent: name, mapping: mappingOf(name), handler };
|
|
162
|
+
bindings.add(binding);
|
|
163
|
+
startListening();
|
|
164
|
+
return () => {
|
|
165
|
+
bindings.delete(binding);
|
|
166
|
+
if (bindings.size === 0) stopListening();
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
destroy() {
|
|
170
|
+
bindings.clear();
|
|
171
|
+
stopListening();
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
export {
|
|
176
|
+
PRESETS,
|
|
177
|
+
yubisac
|
|
178
|
+
};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
"use strict";var __yubisac=(()=>{var M=Object.defineProperty;var B=Object.getOwnPropertyDescriptor;var H=Object.getOwnPropertyNames;var U=Object.prototype.hasOwnProperty;var G=(t,s)=>{for(var a in s)M(t,a,{get:s[a],enumerable:!0})},z=(t,s,a,m)=>{if(s&&typeof s=="object"||typeof s=="function")for(let u of H(s))!U.call(t,u)&&u!==a&&M(t,u,{get:()=>s[u],enumerable:!(m=B(s,u))||m.enumerable});return t};var F=t=>z(M({},"__esModule",{value:!0}),t);var j={};G(j,{PRESETS:()=>X,yubisac:()=>$});var X={activate:{touch:"tap",pen:"tap",mouse:"click",keyboard:["Enter","Space"]},"secondary-activate":{touch:"long-press",pen:"long-press",mouse:"double-click"},context:{mouse:"right-click",keyboard:"ContextMenu"}},O={click:"single",tap:"single","double-click":"double","double-tap":"double","long-press":"hold","context-menu":"context","right-click":"context"},N=t=>t.toLowerCase()==="space"?" ":t.toLowerCase();function $(t,s={},a={}){let m=a.hold??500,u=a.tolerance??8,l=new Set,y=!1,v=-1/0,_=e=>{let n=s[e]??X[e];if(!n)throw new Error(`yubisac: unknown intent "${e}" (not in profile or presets)`);return n},w=(e,n)=>{for(let r of l){let i=r.mapping[e];if(i&&O[i]===n)return!0}return!1},g=(e,n,r,i,p)=>{for(let d of[...l]){let f=d.mapping[n];f&&O[f]===e&&l.has(d)&&d.handler({intent:d.intent,pointerType:n,clientX:r,clientY:i,currentTarget:t,originalEvent:p})}},D=(e,n,r)=>{let i=t.getBoundingClientRect();n({intent:e,pointerType:"keyboard",clientX:i.left+i.width/2,clientY:i.top+i.height/2,currentTarget:t,originalEvent:r})},c,h=()=>{c&&clearTimeout(c.timer),c=void 0},x,T=e=>e.target?.closest("[data-yubisac-ignore]")!=null,P=e=>{if(!e.isPrimary||T(e)||(e.pointerType==="touch"&&(v=e.timeStamp),e.pointerType==="mouse"&&e.timeStamp-v<500)||e.pointerType==="mouse"&&e.button!==0)return;let n=e.pointerType,r=e.pointerId,i=e.clientX,p=e.clientY,d=!1,f=!1,E=n!=="mouse"?o=>{o.isTrusted&&o.preventDefault()}:void 0;c&&c.pointerType===n&&Math.hypot(i-c.x,p-c.y)<u*3&&(h(),f=!0,g("double",n,i,p,e));let S=w(n,"hold")?setTimeout(()=>{d||f||(f=!0,g("hold",n,i,p,e))},m):void 0,b=()=>{S&&clearTimeout(S),n==="touch"&&(v=performance.now()),window.removeEventListener("pointermove",I),window.removeEventListener("pointerup",C),window.removeEventListener("pointercancel",R),E&&window.removeEventListener("contextmenu",E),x=void 0},I=o=>{o.pointerId===r&&(Math.hypot(o.clientX-i,o.clientY-p)<u||(d=!0,b()))},C=o=>{if(o.pointerId===r&&(b(),!(d||f)))if(w(n,"double")){h();let Y=setTimeout(()=>{c?.timer===Y&&(c=void 0),g("single",n,o.clientX,o.clientY,o)},280);c={timer:Y,x:o.clientX,y:o.clientY,ev:o,pointerType:n}}else g("single",n,o.clientX,o.clientY,o)},R=o=>{o.pointerId===r&&b()};x=b,E&&window.addEventListener("contextmenu",E),window.addEventListener("pointermove",I,{passive:!1}),window.addEventListener("pointerup",C),window.addEventListener("pointercancel",R)},L=e=>{T(e)||e.timeStamp-v<500||w("mouse","context")&&(e.preventDefault(),g("context","mouse",e.clientX,e.clientY,e))},k=e=>{if(e.repeat||T(e)||e.ctrlKey||e.metaKey||e.altKey)return;let n=e.key.toLowerCase();for(let r of[...l]){let i=r.mapping.keyboard;if(!i)continue;(Array.isArray(i)?i:[i]).some(d=>N(d)===n)&&l.has(r)&&(n===" "&&e.preventDefault(),D(r.intent,r.handler,e))}},A=()=>{y||(y=!0,t.addEventListener("pointerdown",P),t.addEventListener("contextmenu",L),t.addEventListener("keydown",k))},K=()=>{y&&(y=!1,t.removeEventListener("pointerdown",P),t.removeEventListener("contextmenu",L),t.removeEventListener("keydown",k),x?.(),h())};return{clingTo(e,n){let r={intent:e,mapping:_(e),handler:n};return l.add(r),A(),()=>{l.delete(r),l.size===0&&K()}},destroy(){l.clear(),K()}}}return F(j);})();
|
|
2
|
+
window.yubisac=__yubisac.yubisac;
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './core'
|
package/native.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { yubisac, type Finger, type IntentHandler, type Profile, type YubisacConfig } from './core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* yubisac — native (vanilla DOM) adapter.
|
|
5
|
+
*
|
|
6
|
+
* The core is already framework-free (`yubisac(el, …)`), so this only adds one convenience: bind a whole
|
|
7
|
+
* map of intent → handler in a single call. Everything from `./core` is re-exported. Needs no dependencies.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { cling } from 'yubisac/native'
|
|
11
|
+
* const finger = cling(card, { inspect: openInspector }, { profile: { inspect: { mouse: 'double-click', touch: 'long-press' } } })
|
|
12
|
+
* finger.destroy()
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export * from './core'
|
|
16
|
+
|
|
17
|
+
/** Attach a map of intents to an element in one call; returns the {@link Finger} (call `destroy()` to unbind). */
|
|
18
|
+
export function cling(el: HTMLElement, on: Record<string, IntentHandler>, opts?: { profile?: Profile; config?: YubisacConfig }): Finger {
|
|
19
|
+
const finger = yubisac(el, opts?.profile, opts?.config)
|
|
20
|
+
for (const [name, handler] of Object.entries(on)) finger.clingTo(name, handler)
|
|
21
|
+
return finger
|
|
22
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "yubisac",
|
|
3
|
+
"version": "0.9.1",
|
|
4
|
+
"description": "Touch-first intent binding. Design for the tablet, then let mouse, pen and keyboard cling to the same intent — so your app speaks in intents, not events.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "mrksye",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/mrksye/yubisac.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/mrksye/yubisac/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/mrksye/yubisac#readme",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"intent",
|
|
18
|
+
"gesture",
|
|
19
|
+
"pointer-events",
|
|
20
|
+
"touch",
|
|
21
|
+
"pen",
|
|
22
|
+
"long-press",
|
|
23
|
+
"double-click",
|
|
24
|
+
"accessibility",
|
|
25
|
+
"input",
|
|
26
|
+
"solid-js",
|
|
27
|
+
"solidjs",
|
|
28
|
+
"react",
|
|
29
|
+
"vue",
|
|
30
|
+
"svelte"
|
|
31
|
+
],
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"scripts": {
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"test": "bun test/yubisac.test.mjs",
|
|
36
|
+
"build": "rm -rf dist && node build.mjs && tsc -p tsconfig.build.json",
|
|
37
|
+
"prepack": "npm run build"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"typescript": "^5.4.0",
|
|
41
|
+
"esbuild": "^0.28.1",
|
|
42
|
+
"playwright": "^1.40.0",
|
|
43
|
+
"solid-js": "^1.8.0",
|
|
44
|
+
"react": "^18.3.0",
|
|
45
|
+
"@types/react": "^18.3.0",
|
|
46
|
+
"vue": "^3.4.0",
|
|
47
|
+
"svelte": "^5.0.0"
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"dist",
|
|
51
|
+
"core.ts",
|
|
52
|
+
"native.ts",
|
|
53
|
+
"solid.tsx",
|
|
54
|
+
"react.tsx",
|
|
55
|
+
"react-native.tsx",
|
|
56
|
+
"vue.ts",
|
|
57
|
+
"svelte.ts",
|
|
58
|
+
"index.ts",
|
|
59
|
+
"README.md",
|
|
60
|
+
"EXAMPLES.md",
|
|
61
|
+
"CONTRIBUTING.md",
|
|
62
|
+
"LICENSE"
|
|
63
|
+
],
|
|
64
|
+
"main": "./dist/core.js",
|
|
65
|
+
"module": "./dist/core.js",
|
|
66
|
+
"types": "./dist/core.d.ts",
|
|
67
|
+
"unpkg": "./dist/yubisac.global.js",
|
|
68
|
+
"jsdelivr": "./dist/yubisac.global.js",
|
|
69
|
+
"exports": {
|
|
70
|
+
".": { "types": "./dist/core.d.ts", "import": "./dist/core.js" },
|
|
71
|
+
"./core": { "types": "./dist/core.d.ts", "import": "./dist/core.js" },
|
|
72
|
+
"./global": "./dist/yubisac.global.js",
|
|
73
|
+
"./native": "./native.ts",
|
|
74
|
+
"./solid": "./solid.tsx",
|
|
75
|
+
"./react": "./react.tsx",
|
|
76
|
+
"./react-native": "./react-native.tsx",
|
|
77
|
+
"./vue": "./vue.ts",
|
|
78
|
+
"./svelte": "./svelte.ts"
|
|
79
|
+
},
|
|
80
|
+
"peerDependencies": {
|
|
81
|
+
"solid-js": ">=1.6.0",
|
|
82
|
+
"react": ">=18.0.0",
|
|
83
|
+
"react-native": ">=0.70.0",
|
|
84
|
+
"vue": ">=3.2.0",
|
|
85
|
+
"svelte": ">=4.0.0"
|
|
86
|
+
},
|
|
87
|
+
"peerDependenciesMeta": {
|
|
88
|
+
"solid-js": { "optional": true },
|
|
89
|
+
"react": { "optional": true },
|
|
90
|
+
"react-native": { "optional": true },
|
|
91
|
+
"vue": { "optional": true },
|
|
92
|
+
"svelte": { "optional": true }
|
|
93
|
+
}
|
|
94
|
+
}
|
package/react-native.tsx
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/** @jsxImportSource react */
|
|
2
|
+
import { useEffect, useMemo, useRef, type ReactNode } from 'react'
|
|
3
|
+
import { PanResponder, View, type GestureResponderEvent, type StyleProp, type ViewStyle } from 'react-native'
|
|
4
|
+
import { PRESETS, type Profile, type YubisacConfig } from './core'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* yubisac — React Native adapter (experimental).
|
|
8
|
+
*
|
|
9
|
+
* React Native has no DOM or Pointer Events, so this does NOT reuse the web core (`./core`) — it ships a
|
|
10
|
+
* tiny `PanResponder`-based recogniser instead (no extra deps). Only the `touch` mapping of each intent
|
|
11
|
+
* applies (there is no mouse/pen/keyboard here), and recognition is the same small set: tap / double-tap /
|
|
12
|
+
* long-press. It claims the touch on start but yields to a parent (a `ScrollView`) on request, so scrolling
|
|
13
|
+
* still works. Outside the web core's guarantees. `react` and `react-native` are optional peer dependencies.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** What a bound handler receives on React Native (screen coordinates; no DOM node). */
|
|
17
|
+
export type RNIntentEvent = { intent: string; x: number; y: number }
|
|
18
|
+
export type RNIntentHandler = (e: RNIntentEvent) => void
|
|
19
|
+
|
|
20
|
+
/** The `touch` gestures that map to a recogniser on RN (mouse/pen/keyboard tokens are ignored here). */
|
|
21
|
+
const CATEGORY: Record<string, 'single' | 'double' | 'hold' | undefined> = {
|
|
22
|
+
tap: 'single',
|
|
23
|
+
click: 'single',
|
|
24
|
+
'double-tap': 'double',
|
|
25
|
+
'double-click': 'double',
|
|
26
|
+
'long-press': 'hold',
|
|
27
|
+
}
|
|
28
|
+
/** A second tap within this long (ms) resolves the pair as a double. */
|
|
29
|
+
const DOUBLE_MS = 280
|
|
30
|
+
|
|
31
|
+
/** Bind intents to a View. Spread the returned handlers onto a `<View {...panHandlers}>`. */
|
|
32
|
+
export function useCling(on: Record<string, RNIntentHandler>, opts?: { profile?: Profile; config?: YubisacConfig }) {
|
|
33
|
+
const latest = useRef({ on, opts })
|
|
34
|
+
latest.current = { on, opts }
|
|
35
|
+
const s = useRef({ startX: 0, startY: 0, moved: false, held: false, lastTapAt: 0, holdTimer: undefined as ReturnType<typeof setTimeout> | undefined, singleTimer: undefined as ReturnType<typeof setTimeout> | undefined })
|
|
36
|
+
// Clear any armed timer on unmount so it never fires a handler on a gone component.
|
|
37
|
+
useEffect(() => () => {
|
|
38
|
+
if (s.current.holdTimer) clearTimeout(s.current.holdTimer)
|
|
39
|
+
if (s.current.singleTimer) clearTimeout(s.current.singleTimer)
|
|
40
|
+
}, [])
|
|
41
|
+
|
|
42
|
+
const panHandlers = useMemo(() => {
|
|
43
|
+
const cfg = () => latest.current.opts?.config
|
|
44
|
+
const mappingOf = (name: string) => latest.current.opts?.profile?.[name] ?? PRESETS[name]
|
|
45
|
+
const wants = (cat: 'single' | 'double' | 'hold'): boolean =>
|
|
46
|
+
Object.keys(latest.current.on).some((n) => {
|
|
47
|
+
const g = mappingOf(n)?.touch
|
|
48
|
+
return g !== undefined && CATEGORY[g] === cat
|
|
49
|
+
})
|
|
50
|
+
const fire = (cat: 'single' | 'double' | 'hold', x: number, y: number): void => {
|
|
51
|
+
for (const [name, handler] of Object.entries(latest.current.on)) {
|
|
52
|
+
const g = mappingOf(name)?.touch
|
|
53
|
+
if (g !== undefined && CATEGORY[g] === cat) handler({ intent: name, x, y })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const clearTimers = (): void => {
|
|
57
|
+
if (s.current.holdTimer) clearTimeout(s.current.holdTimer)
|
|
58
|
+
if (s.current.singleTimer) clearTimeout(s.current.singleTimer)
|
|
59
|
+
s.current.holdTimer = undefined
|
|
60
|
+
s.current.singleTimer = undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return PanResponder.create({
|
|
64
|
+
onStartShouldSetPanResponder: () => true,
|
|
65
|
+
onMoveShouldSetPanResponder: () => false, // never claim a move → a parent ScrollView keeps scrolling
|
|
66
|
+
onPanResponderTerminationRequest: () => true, // let scroll/parent win; we abandon recognition
|
|
67
|
+
onPanResponderGrant: (e: GestureResponderEvent) => {
|
|
68
|
+
clearTimers()
|
|
69
|
+
s.current.startX = e.nativeEvent.pageX
|
|
70
|
+
s.current.startY = e.nativeEvent.pageY
|
|
71
|
+
s.current.moved = false
|
|
72
|
+
s.current.held = false
|
|
73
|
+
if (wants('hold')) {
|
|
74
|
+
s.current.holdTimer = setTimeout(() => {
|
|
75
|
+
if (s.current.moved) return
|
|
76
|
+
s.current.held = true
|
|
77
|
+
fire('hold', s.current.startX, s.current.startY)
|
|
78
|
+
}, cfg()?.hold ?? 500)
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
onPanResponderMove: (e: GestureResponderEvent) => {
|
|
82
|
+
const dx = e.nativeEvent.pageX - s.current.startX
|
|
83
|
+
const dy = e.nativeEvent.pageY - s.current.startY
|
|
84
|
+
if (Math.hypot(dx, dy) < (cfg()?.tolerance ?? 8)) return
|
|
85
|
+
s.current.moved = true
|
|
86
|
+
if (s.current.holdTimer) clearTimeout(s.current.holdTimer)
|
|
87
|
+
},
|
|
88
|
+
onPanResponderRelease: (e: GestureResponderEvent) => {
|
|
89
|
+
if (s.current.holdTimer) clearTimeout(s.current.holdTimer)
|
|
90
|
+
if (s.current.moved || s.current.held) return
|
|
91
|
+
const x = e.nativeEvent.pageX
|
|
92
|
+
const y = e.nativeEvent.pageY
|
|
93
|
+
const now = Date.now()
|
|
94
|
+
if (wants('double') && now - s.current.lastTapAt < DOUBLE_MS) {
|
|
95
|
+
if (s.current.singleTimer) clearTimeout(s.current.singleTimer)
|
|
96
|
+
s.current.singleTimer = undefined
|
|
97
|
+
s.current.lastTapAt = 0
|
|
98
|
+
fire('double', x, y)
|
|
99
|
+
} else if (wants('double')) {
|
|
100
|
+
s.current.lastTapAt = now
|
|
101
|
+
s.current.singleTimer = setTimeout(() => fire('single', x, y), DOUBLE_MS)
|
|
102
|
+
} else {
|
|
103
|
+
fire('single', x, y)
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
onPanResponderTerminate: () => {
|
|
107
|
+
clearTimers()
|
|
108
|
+
s.current.moved = false
|
|
109
|
+
s.current.held = false
|
|
110
|
+
},
|
|
111
|
+
}).panHandlers
|
|
112
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
113
|
+
}, [])
|
|
114
|
+
|
|
115
|
+
return panHandlers
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** JSX wrapper: a View that binds `on` (intent → handler). */
|
|
119
|
+
export function Cling({
|
|
120
|
+
on,
|
|
121
|
+
profile,
|
|
122
|
+
config,
|
|
123
|
+
style,
|
|
124
|
+
children,
|
|
125
|
+
}: {
|
|
126
|
+
on: Record<string, RNIntentHandler>
|
|
127
|
+
profile?: Profile
|
|
128
|
+
config?: YubisacConfig
|
|
129
|
+
style?: StyleProp<ViewStyle>
|
|
130
|
+
children: ReactNode
|
|
131
|
+
}) {
|
|
132
|
+
const panHandlers = useCling(on, { profile, config })
|
|
133
|
+
return (
|
|
134
|
+
<View style={style} {...panHandlers}>
|
|
135
|
+
{children}
|
|
136
|
+
</View>
|
|
137
|
+
)
|
|
138
|
+
}
|
package/react.tsx
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** @jsxImportSource react */
|
|
2
|
+
import { useEffect, useRef, type CSSProperties, type ReactNode, type RefObject } from 'react'
|
|
3
|
+
import { yubisac, type IntentHandler, type Profile, type YubisacConfig } from './core'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* yubisac — React adapter.
|
|
7
|
+
*
|
|
8
|
+
* yubisac is element-local — no shared context, so no provider. `useCling` returns a ref you put on an
|
|
9
|
+
* element; it binds the intents on mount and removes every listener on unmount. Handlers are read through a
|
|
10
|
+
* ref so the stable binding always calls the current one. `react` is an optional peer dependency.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Bind intents to an element via the returned ref. Pass a `profile` for custom intents; presets work without one. */
|
|
14
|
+
export function useCling<T extends HTMLElement = HTMLDivElement>(
|
|
15
|
+
on: Record<string, IntentHandler>,
|
|
16
|
+
opts?: { profile?: Profile; config?: YubisacConfig },
|
|
17
|
+
): RefObject<T> {
|
|
18
|
+
const ref = useRef<T>(null)
|
|
19
|
+
const latest = useRef(on)
|
|
20
|
+
latest.current = on
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const el = ref.current
|
|
23
|
+
if (!el) return
|
|
24
|
+
const finger = yubisac(el, opts?.profile, opts?.config)
|
|
25
|
+
for (const name of Object.keys(latest.current)) finger.clingTo(name, (e) => latest.current[name]?.(e))
|
|
26
|
+
return () => finger.destroy()
|
|
27
|
+
// Bind once; profile/config are treated as create-time options (like goonteh's kind).
|
|
28
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
29
|
+
}, [])
|
|
30
|
+
return ref
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** JSX wrapper: binds `on` (intent → handler) to its own element. */
|
|
34
|
+
export function Cling({
|
|
35
|
+
on,
|
|
36
|
+
profile,
|
|
37
|
+
config,
|
|
38
|
+
className,
|
|
39
|
+
style,
|
|
40
|
+
children,
|
|
41
|
+
}: {
|
|
42
|
+
on: Record<string, IntentHandler>
|
|
43
|
+
profile?: Profile
|
|
44
|
+
config?: YubisacConfig
|
|
45
|
+
className?: string
|
|
46
|
+
style?: CSSProperties
|
|
47
|
+
children: ReactNode
|
|
48
|
+
}) {
|
|
49
|
+
const ref = useCling<HTMLDivElement>(on, { profile, config })
|
|
50
|
+
return (
|
|
51
|
+
<div ref={ref} className={className} style={style}>
|
|
52
|
+
{children}
|
|
53
|
+
</div>
|
|
54
|
+
)
|
|
55
|
+
}
|
package/solid.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { onCleanup, type JSX } from 'solid-js'
|
|
2
|
+
import { yubisac, type IntentHandler, type Profile, type YubisacConfig } from './core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SolidJS binding for yubisac. yubisac is element-local (no shared/global state like goonteh's drag
|
|
6
|
+
* context), so there is no Provider — you just attach intents to an element.
|
|
7
|
+
*
|
|
8
|
+
* `cling` fits an element's `ref` and binds a map of intent → handler; `<Cling>` is the JSX wrapper.
|
|
9
|
+
* Both register on mount and remove every listener on cleanup.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Bind intents to an element via its `ref`. Pass a `profile` to define custom intents; built-in presets work without one. */
|
|
13
|
+
export function cling(
|
|
14
|
+
on: Record<string, IntentHandler>,
|
|
15
|
+
opts?: { profile?: Profile; config?: YubisacConfig },
|
|
16
|
+
): (el: HTMLElement) => void {
|
|
17
|
+
return (el) => {
|
|
18
|
+
const finger = yubisac(el, opts?.profile, opts?.config)
|
|
19
|
+
for (const [name, handler] of Object.entries(on)) finger.clingTo(name, handler)
|
|
20
|
+
onCleanup(() => finger.destroy())
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** JSX wrapper: binds `on` (intent → handler) to its own element. `class`/`style` pass through. */
|
|
25
|
+
export function Cling(props: {
|
|
26
|
+
on: Record<string, IntentHandler>
|
|
27
|
+
profile?: Profile
|
|
28
|
+
config?: YubisacConfig
|
|
29
|
+
class?: string
|
|
30
|
+
style?: JSX.CSSProperties
|
|
31
|
+
children: JSX.Element
|
|
32
|
+
}): JSX.Element {
|
|
33
|
+
return (
|
|
34
|
+
<div ref={cling(props.on, { profile: props.profile, config: props.config })} class={props.class} style={props.style}>
|
|
35
|
+
{props.children}
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
package/svelte.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { yubisac, type Finger, type IntentHandler, type Profile, type YubisacConfig } from './core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* yubisac — Svelte adapter.
|
|
5
|
+
*
|
|
6
|
+
* Idiomatic Svelte: a use-directive **action** that attaches intents to an element. Built on the
|
|
7
|
+
* framework-agnostic core (`./core`); `svelte` is an optional peer dependency.
|
|
8
|
+
*
|
|
9
|
+
* ```svelte
|
|
10
|
+
* <script>
|
|
11
|
+
* import { cling } from 'yubisac/svelte'
|
|
12
|
+
* </script>
|
|
13
|
+
* <div use:cling={{ on: { inspect: openInspector }, profile: { inspect: { mouse: 'double-click', touch: 'long-press' } } }}>…</div>
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export type ClingParams = { on: Record<string, IntentHandler>; profile?: Profile; config?: YubisacConfig }
|
|
17
|
+
|
|
18
|
+
const bind = (el: HTMLElement, p: ClingParams): Finger => {
|
|
19
|
+
const finger = yubisac(el, p.profile, p.config)
|
|
20
|
+
for (const [name, handler] of Object.entries(p.on)) finger.clingTo(name, handler)
|
|
21
|
+
return finger
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `use:cling={{ on, profile?, config? }}` — binds intents to the element; rebinds on param change. */
|
|
25
|
+
export function cling(el: HTMLElement, params: ClingParams): { update(p: ClingParams): void; destroy(): void } {
|
|
26
|
+
let finger = bind(el, params)
|
|
27
|
+
return {
|
|
28
|
+
update: (next) => {
|
|
29
|
+
finger.destroy()
|
|
30
|
+
finger = bind(el, next)
|
|
31
|
+
},
|
|
32
|
+
destroy: () => finger.destroy(),
|
|
33
|
+
}
|
|
34
|
+
}
|
package/vue.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Directive } from 'vue'
|
|
2
|
+
import { yubisac, type Finger, type IntentHandler, type Profile, type YubisacConfig } from './core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* yubisac — Vue 3 adapter.
|
|
6
|
+
*
|
|
7
|
+
* yubisac attaches behaviour to one element with no shared state, which is exactly what a Vue **directive**
|
|
8
|
+
* is for — so this is a directive, not components. `vue` is an optional peer dependency.
|
|
9
|
+
*
|
|
10
|
+
* ```vue
|
|
11
|
+
* <script setup>
|
|
12
|
+
* import { vCling } from 'yubisac/vue'
|
|
13
|
+
* </script>
|
|
14
|
+
* <div v-cling="{ on: { inspect: openInspector }, profile: { inspect: { mouse: 'double-click', touch: 'long-press' } } }">…</div>
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export type YubisacBinding = { on: Record<string, IntentHandler>; profile?: Profile; config?: YubisacConfig }
|
|
18
|
+
|
|
19
|
+
type BoundEl = HTMLElement & { __yubisac?: Finger }
|
|
20
|
+
|
|
21
|
+
const bind = (el: BoundEl, value: YubisacBinding): void => {
|
|
22
|
+
el.__yubisac?.destroy()
|
|
23
|
+
const finger = yubisac(el, value.profile, value.config)
|
|
24
|
+
for (const [name, handler] of Object.entries(value.on)) finger.clingTo(name, handler)
|
|
25
|
+
el.__yubisac = finger
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `v-cling="{ on, profile?, config? }"` — binds intents to the element for its lifetime. */
|
|
29
|
+
export const vCling: Directive<BoundEl, YubisacBinding> = {
|
|
30
|
+
mounted: (el, binding) => bind(el, binding.value),
|
|
31
|
+
// Only rebind when the binding value actually changed — a re-render alone must not tear down a live press.
|
|
32
|
+
updated: (el, binding) => {
|
|
33
|
+
if (binding.value !== binding.oldValue) bind(el, binding.value)
|
|
34
|
+
},
|
|
35
|
+
unmounted: (el) => {
|
|
36
|
+
el.__yubisac?.destroy()
|
|
37
|
+
el.__yubisac = undefined
|
|
38
|
+
},
|
|
39
|
+
}
|