Skip to content

Commit 921baeb

Browse files
committed
feat: add PinchMotion
1 parent 34c803c commit 921baeb

5 files changed

Lines changed: 460 additions & 1 deletion

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,23 @@ npm i github:TheProfs/pointerdriver
1313
## Usage
1414

1515
```js
16-
import { DragMotion, GlideMotion, StrokeMotion } from 'pointerdriver'
16+
import { DragMotion, GlideMotion, StrokeMotion, PinchMotion } from 'pointerdriver'
1717

1818
const el = document.querySelector('#el')
1919

2020
await new DragMotion(el, [
2121
[30, 50, 0],
2222
[60, 80, 16],
2323
]).perform()
24+
25+
await new PinchMotion(el, 2, { x: 60, y: 80 }).perform()
2426
```
2527

2628
`DragMotion` (mouse), `GlideMotion` (finger), and `StrokeMotion` (pen)
2729
take points as `[x, y, ms]`.
2830
`ms` is the timestamp offset since the start of the motion.
2931
Timestamps must be non-decreasing.
32+
`PinchMotion` takes `scale` and `{ x, y, distance, steps }`.
3033

3134
## Spec
3235

index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { DragMotion } from './src/motions/drag/index.js'
22
export { GlideMotion } from './src/motions/glide/index.js'
33
export { StrokeMotion } from './src/motions/stroke/index.js'
4+
export { PinchMotion } from './src/motions/pinch/index.js'

src/motions/pinch/index.js

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { Motion } from '../../motion/index.js'
2+
3+
const lerp = (a, b, t) =>
4+
a + (b - a) * t
5+
6+
const computeGesture = (a, b, base) => {
7+
const dx = b.x - a.x
8+
const dy = b.y - a.y
9+
const dist = Math.hypot(dx, dy)
10+
11+
const scale = dist / base.dist
12+
const rotation = (Math.atan2(dy, dx) - base.angle) * (180 / Math.PI)
13+
14+
return { scale, rotation }
15+
}
16+
17+
const frameDelay = () =>
18+
new Promise(resolve => requestAnimationFrame(() => resolve()))
19+
20+
export class PinchMotion extends Motion {
21+
#scale
22+
#center
23+
#distance
24+
#steps
25+
26+
constructor(el, scale, opts = {}) {
27+
const { x, y, distance = 100, steps = 20, ...rest } = opts
28+
super(el, rest)
29+
30+
if (typeof scale !== 'number' || !Number.isFinite(scale))
31+
throw new TypeError('PinchMotion.scale must be a finite number')
32+
33+
if (scale <= 0)
34+
throw new RangeError('PinchMotion.scale must be > 0')
35+
36+
if (typeof x !== 'number' || !Number.isFinite(x))
37+
throw new TypeError('PinchMotion.x must be a finite number')
38+
39+
if (typeof y !== 'number' || !Number.isFinite(y))
40+
throw new TypeError('PinchMotion.y must be a finite number')
41+
42+
if (typeof distance !== 'number' || !Number.isFinite(distance))
43+
throw new TypeError('PinchMotion.distance must be a finite number')
44+
45+
if (distance <= 0)
46+
throw new RangeError('PinchMotion.distance must be > 0')
47+
48+
if (!Number.isInteger(steps) || steps <= 0)
49+
throw new RangeError('PinchMotion.steps must be positive integer')
50+
51+
this.#scale = scale
52+
this.#center = { x, y }
53+
this.#distance = distance
54+
this.#steps = steps
55+
}
56+
57+
get device() { return 'touch' }
58+
59+
async perform() {
60+
const scale = this.#scale
61+
const center = this.#center
62+
const distance = this.#distance
63+
const steps = this.#steps
64+
65+
const pos = Array.from({ length: steps + 1 }, (_, i) => {
66+
const t = i / steps
67+
const d = (distance * lerp(1, scale, t)) / 2
68+
69+
return [
70+
{ x: center.x - d, y: center.y },
71+
{ x: center.x + d, y: center.y },
72+
]
73+
})
74+
75+
const n = pos.length
76+
77+
const a0 = this.hit(pos[0][0])
78+
const b0 = this.hit(pos[0][1])
79+
80+
const a = this.pointer({ primary: true })
81+
const b = this.pointer({ primary: false })
82+
83+
const base = {
84+
dist: Math.hypot(pos[0][1].x - pos[0][0].x, pos[0][1].y - pos[0][0].y),
85+
angle: Math.atan2(pos[0][1].y - pos[0][0].y, pos[0][1].x - pos[0][0].x),
86+
}
87+
88+
let gesture = { scale: 1, rotation: 0 }
89+
let lastPos = pos[0]
90+
let gestureStarted = false
91+
let gestureEnded = false
92+
93+
try {
94+
a.enter(a0, pos[0][0])
95+
a.down(a0, pos[0][0], 0, n)
96+
this.touchstart(a, pos[0][0], { scale: 1, rotation: 0 })
97+
98+
this.gesturestart(a0, { scale: 1, rotation: 0 })
99+
gestureStarted = true
100+
101+
b.enter(b0, pos[0][1])
102+
b.down(b0, pos[0][1], 0, n)
103+
this.touchstart(b, pos[0][1], { scale: 1, rotation: 0 })
104+
105+
a.capture(a0)
106+
b.capture(b0)
107+
108+
for (let i = 1; i < n; i++) {
109+
await frameDelay()
110+
111+
this.hit(pos[i][0])
112+
this.hit(pos[i][1])
113+
114+
lastPos = pos[i]
115+
gesture = computeGesture(pos[i][0], pos[i][1], base)
116+
117+
a.move(a0, pos[i][0], i, n)
118+
this.touchmove(a, pos[i][0], gesture)
119+
120+
b.move(b0, pos[i][1], i, n)
121+
this.touchmove(b, pos[i][1], gesture)
122+
123+
if (i === 1) {
124+
this.gesturestart(b0, gesture)
125+
this.gesturechange(a0, gesture)
126+
} else {
127+
this.gesturechange(b0, gesture)
128+
this.gesturechange(a0, gesture)
129+
}
130+
}
131+
132+
this.gestureend(b0, gesture)
133+
this.gestureend(a0, gesture)
134+
gestureEnded = true
135+
136+
a.up(a0, pos[n - 1][0], n - 1, n)
137+
a.release(a0)
138+
a.leave(a0, pos[n - 1][0])
139+
140+
this.touchend(a, pos[n - 1][0], gesture)
141+
142+
b.up(b0, pos[n - 1][1], n - 1, n)
143+
b.release(b0)
144+
b.leave(b0, pos[n - 1][1])
145+
146+
this.touchend(b, pos[n - 1][1], { scale: 1, rotation: 0 })
147+
} catch (err) {
148+
if (gestureStarted && !gestureEnded) {
149+
this.gestureend(b0, gesture)
150+
this.gestureend(a0, gesture)
151+
}
152+
153+
a.cancel(a0, lastPos[0])
154+
this.touchcancel(a, lastPos[0])
155+
156+
b.cancel(b0, lastPos[1])
157+
this.touchcancel(b, lastPos[1])
158+
159+
throw new Error(
160+
`pinch aborted: ${err?.message ?? String(err)}`,
161+
{ cause: err }
162+
)
163+
}
164+
}
165+
}

0 commit comments

Comments
 (0)