Skip to content

Commit 34c803c

Browse files
committed
feat: add PenPointer and StrokeMotion
1 parent 48a4e7f commit 34c803c

10 files changed

Lines changed: 540 additions & 5 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@ npm-debug.log*
55
# llm
66
AGENTS.md
77
.claude/
8+
9+
# artifacts
10+
docs/spec/*.zip

README.md

Lines changed: 3 additions & 2 deletions
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 } from 'pointerdriver'
16+
import { DragMotion, GlideMotion, StrokeMotion } from 'pointerdriver'
1717

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

@@ -23,7 +23,8 @@ await new DragMotion(el, [
2323
]).perform()
2424
```
2525

26-
`DragMotion` points are `[x, y, ms]`.
26+
`DragMotion` (mouse), `GlideMotion` (finger), and `StrokeMotion` (pen)
27+
take points as `[x, y, ms]`.
2728
`ms` is the timestamp offset since the start of the motion.
2829
Timestamps must be non-decreasing.
2930

index.js

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

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"bin": {
1111
"pointerdriver": "bin/pointerdriver.js"
1212
},
13-
"files": ["index.js", "src/", "bin/", "docs/"],
13+
"files": ["index.js", "src/", "bin/", "docs/", "!docs/spec/*.zip"],
1414
"scripts": {
1515
"start": "node ./bin/pointerdriver.js",
1616
"test": "node --test --test-concurrency=1 --test-timeout=5000 \"**/*.test.js\""

src/motions/stroke/index.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { Motion } from '../../motion/index.js'
2+
3+
const normalizePoints = raw => {
4+
if (!Array.isArray(raw))
5+
throw new TypeError('StrokeMotion.points must be [x, y, ms][]')
6+
7+
const points = raw.map((p, i) => {
8+
if (!Array.isArray(p) || p.length !== 3)
9+
throw new TypeError(`points[${i}] must be [x, y, ms]`)
10+
11+
const [x, y, createdAt] = p
12+
13+
if (
14+
![x, y, createdAt]
15+
.every(n => typeof n === 'number' && Number.isFinite(n))
16+
)
17+
throw new TypeError(`points[${i}] must contain finite numbers`)
18+
19+
if (createdAt < 0)
20+
throw new RangeError(`points[${i}][2] must be >= 0`)
21+
22+
return { x, y, createdAt }
23+
})
24+
25+
for (let i = 1; i < points.length; i++) {
26+
if (points[i].createdAt < points[i - 1].createdAt)
27+
throw new RangeError(
28+
`points[${i}][2] must be >= points[${i - 1}][2]`
29+
)
30+
}
31+
32+
return points
33+
}
34+
35+
export class StrokeMotion extends Motion {
36+
#points
37+
38+
constructor(el, points, opts) {
39+
super(el, opts)
40+
this.#points = normalizePoints(points)
41+
}
42+
43+
get device() { return 'pen' }
44+
45+
async perform() {
46+
const points = this.#points
47+
if (!points.length) return
48+
49+
const pointer = this.pointer({ primary: true })
50+
51+
let point = points[0]
52+
let target = this.hit(point)
53+
54+
pointer.enter(target, point)
55+
pointer.down(target, point, 0, points.length)
56+
57+
try {
58+
this.touchstart(pointer, point)
59+
60+
for (let i = 1; i < points.length; i++) {
61+
await this.delay(points[i].createdAt - points[i - 1].createdAt)
62+
63+
target = this.hit(points[i])
64+
point = points[i]
65+
66+
pointer.move(target, point, i, points.length)
67+
this.touchmove(pointer, point)
68+
}
69+
70+
pointer.up(target, point, points.length - 1, points.length)
71+
pointer.leave(target, point)
72+
73+
this.touchend(pointer, point)
74+
} catch (err) {
75+
pointer.cancel(target, point)
76+
this.touchcancel(pointer, point)
77+
throw new Error(
78+
`stroke aborted: ${err?.message ?? String(err)}`,
79+
{ cause: err }
80+
)
81+
}
82+
}
83+
}
84+
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { test } from 'node:test'
2+
import { mockDOM, mockListen } from '#test/utils'
3+
import { StrokeMotion } from '../index.js'
4+
5+
test('StrokeMotion', async t => {
6+
t.beforeEach(t => Object.assign(t, { mockDOM, mockListen }).mockDOM())
7+
8+
await t.test('points with non-array input', async t => {
9+
await t.test('throws TypeError', t => {
10+
const el = document.createElement('div')
11+
12+
t.assert.throws(
13+
() => new StrokeMotion(el, null),
14+
{ name: 'TypeError', message: /StrokeMotion\.points/i }
15+
)
16+
})
17+
})
18+
19+
await t.test('points with negative timestamps', async t => {
20+
await t.test('throws RangeError', t => {
21+
const el = document.createElement('div')
22+
23+
t.assert.throws(
24+
() => new StrokeMotion(el, [[10, 10, -1]]),
25+
{ name: 'RangeError', message: />= 0/i }
26+
)
27+
})
28+
})
29+
30+
await t.test('points with decreasing timestamps', async t => {
31+
await t.test('throws RangeError', t => {
32+
const el = document.createElement('div')
33+
34+
t.assert.throws(
35+
() => new StrokeMotion(el, [
36+
[10, 10, 1],
37+
[11, 11, 0],
38+
]),
39+
{ name: 'RangeError', message: /points\[1\]\[2\]/i }
40+
)
41+
})
42+
})
43+
44+
await t.test('pen stroke within element', async t => {
45+
t.beforeEach(t => Object.assign(t, {
46+
stage: document.body.appendChild(
47+
Object.assign(document.createElement('div'), { id: 'stage' })
48+
),
49+
a: document.createElement('div'),
50+
}))
51+
52+
t.beforeEach(t => {
53+
t.a.id = 'a'
54+
t.stage.append(t.a)
55+
document.elementFromPoint = () => t.a
56+
})
57+
58+
await t.test('dispatches expected event sequence', async t => {
59+
const dispatched = t.mockListen([
60+
'pointerover',
61+
'pointerenter',
62+
'pointerdown',
63+
'touchstart',
64+
'gotpointercapture',
65+
'pointermove',
66+
'touchmove',
67+
'pointerup',
68+
'lostpointercapture',
69+
'pointerout',
70+
'pointerleave',
71+
'touchend',
72+
])
73+
74+
await new StrokeMotion(t.stage, [
75+
[10, 10, 0],
76+
[11, 11, 0],
77+
]).perform()
78+
79+
t.assert.eventSequence(dispatched, [
80+
'pointerover@a',
81+
'pointerenter@HTML',
82+
'pointerenter@BODY',
83+
'pointerenter@stage',
84+
'pointerenter@a',
85+
'pointerdown@a',
86+
'touchstart@a',
87+
'pointermove@a',
88+
'touchmove@a',
89+
'pointerup@a',
90+
'pointerout@a',
91+
'pointerleave@a',
92+
'pointerleave@stage',
93+
'pointerleave@BODY',
94+
'pointerleave@HTML',
95+
'touchend@a',
96+
])
97+
})
98+
99+
await t.test('dispatches pointermove for each point after first', async t => {
100+
const dispatched = t.mockListen(['pointermove'])
101+
102+
await new StrokeMotion(t.stage, [
103+
[10, 10, 0],
104+
[11, 11, 0],
105+
[12, 12, 0],
106+
]).perform()
107+
108+
t.assert.strictEqual(dispatched.length, 2)
109+
})
110+
111+
await t.test('uses pointerType pen', async t => {
112+
const dispatched = t.mockListen([
113+
'pointerover',
114+
'pointerenter',
115+
'pointerdown',
116+
'pointermove',
117+
'pointerup',
118+
'pointerout',
119+
'pointerleave',
120+
])
121+
122+
await new StrokeMotion(t.stage, [
123+
[10, 10, 0],
124+
[11, 11, 0],
125+
]).perform()
126+
127+
t.assert.everyPartialEqual(dispatched, { pointerType: 'pen' })
128+
})
129+
})
130+
131+
await t.test('hit-test miss mid-motion', async t => {
132+
t.beforeEach(t => Object.assign(t, {
133+
stage: document.body.appendChild(
134+
Object.assign(document.createElement('div'), { id: 'stage' })
135+
),
136+
a: document.createElement('div'),
137+
}))
138+
139+
t.beforeEach(t => {
140+
t.a.id = 'a'
141+
t.stage.append(t.a)
142+
document.elementFromPoint = x => (x < 20 ? t.a : null)
143+
})
144+
145+
await t.test('rejects with hit-test missed error', async t => {
146+
await t.assert.rejects(
147+
() => new StrokeMotion(t.stage, [
148+
[10, 10, 0],
149+
[30, 10, 0],
150+
]).perform(),
151+
{ name: 'Error', message: /hit-test missed/i }
152+
)
153+
})
154+
155+
await t.test('dispatches pointercancel then leave then touchcancel', async t => {
156+
const dispatched = t.mockListen([
157+
'pointerover',
158+
'pointerenter',
159+
'pointerdown',
160+
'touchstart',
161+
'pointercancel',
162+
'pointerout',
163+
'pointerleave',
164+
'touchcancel',
165+
])
166+
167+
await new StrokeMotion(t.stage, [
168+
[10, 10, 0],
169+
[30, 10, 0],
170+
]).perform().catch(() => null)
171+
172+
t.assert.eventSequence(dispatched, [
173+
'pointerover@a',
174+
'pointerenter@HTML',
175+
'pointerenter@BODY',
176+
'pointerenter@stage',
177+
'pointerenter@a',
178+
'pointerdown@a',
179+
'touchstart@a',
180+
'pointercancel@a',
181+
'pointerout@a',
182+
'pointerleave@a',
183+
'pointerleave@stage',
184+
'pointerleave@BODY',
185+
'pointerleave@HTML',
186+
'touchcancel@a',
187+
])
188+
})
189+
})
190+
})
191+

0 commit comments

Comments
 (0)