Skip to content

Commit 7c1272b

Browse files
committed
feat: add TwistMotion
1 parent 921baeb commit 7c1272b

4 files changed

Lines changed: 439 additions & 1 deletion

File tree

README.md

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

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

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

@@ -23,13 +23,16 @@ await new DragMotion(el, [
2323
]).perform()
2424

2525
await new PinchMotion(el, 2, { x: 60, y: 80 }).perform()
26+
27+
await new TwistMotion(el, 45, { x: 60, y: 80 }).perform()
2628
```
2729

2830
`DragMotion` (mouse), `GlideMotion` (finger), and `StrokeMotion` (pen)
2931
take points as `[x, y, ms]`.
3032
`ms` is the timestamp offset since the start of the motion.
3133
Timestamps must be non-decreasing.
3234
`PinchMotion` takes `scale` and `{ x, y, distance, steps }`.
35+
`TwistMotion` takes `degrees` and `{ x, y, radius, steps }` (degrees can be negative).
3336

3437
## Spec
3538

index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ 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'
44
export { PinchMotion } from './src/motions/pinch/index.js'
5+
export { TwistMotion } from './src/motions/twist/index.js'

src/motions/twist/index.js

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

0 commit comments

Comments
 (0)