Skip to content

Commit 4a478ce

Browse files
authored
Merge pull request #434 from mosch/feat/pinch-to-zoom
Add pinch-to-zoom and wheel zoom support
2 parents 9421c64 + e1cf3f9 commit 4a478ce

7 files changed

Lines changed: 179 additions & 8 deletions

packages/demo/src/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ const App = () => {
141141
borderColor={hexToRgba(state.borderColor)}
142142
onPositionChange={(position: Position) => update({ position })}
143143
onRequestScaleChange={(scale: number) => update({ scale })}
144+
enableWheelZoom
144145
/>
145146
<input {...getInputProps()} />
146147
<span className="dropzone-hint">drop image here</span>

packages/lib/src/index.ts

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export interface Props extends AvatarEditorConfig {
3333
onMouseMove?: (e: TouchEvent | MouseEvent) => void
3434
onPositionChange?: (position: Position) => void
3535
onRequestScaleChange?: (scale: number) => void
36+
enableWheelZoom?: boolean
3637
}
3738

3839
export type { Position, ImageState }
@@ -70,6 +71,7 @@ const AvatarEditor = forwardRef<AvatarEditorRef, Props>((props, ref) => {
7071
onMouseMove,
7172
onPositionChange,
7273
onRequestScaleChange,
74+
enableWheelZoom = false,
7375
borderColor,
7476
style,
7577
keyboardStep = 1,
@@ -102,14 +104,23 @@ const AvatarEditor = forwardRef<AvatarEditorRef, Props>((props, ref) => {
102104
const mxRef = useRef<number | undefined>(undefined)
103105
const myRef = useRef<number | undefined>(undefined)
104106

107+
// Pinch-to-zoom state
108+
const pinchRef = useRef(false)
109+
const pinchStartDistRef = useRef<number>(0)
110+
const pinchStartScaleRef = useRef<number>(1)
111+
105112
// Keep state for `drag` and `loading` to trigger re-renders
106113
const [drag, setDrag] = useState(false)
107114
const [loading, setLoading] = useState(false)
108115
const [imageState, setImageState] = useState<ImageState>(
109116
coreRef.current.getImageState(),
110117
)
111118

112-
// Store latest callback props in refs so document handlers always call current versions
119+
// Store latest prop values in refs so document handlers always have current versions
120+
const scaleRef = useRef(scale)
121+
scaleRef.current = scale
122+
const enableWheelZoomRef = useRef(enableWheelZoom)
123+
enableWheelZoomRef.current = enableWheelZoom
113124
const onMouseUpRef = useRef(onMouseUp)
114125
onMouseUpRef.current = onMouseUp
115126
const onMouseMoveRef = useRef(onMouseMove)
@@ -243,13 +254,26 @@ const AvatarEditor = forwardRef<AvatarEditorRef, Props>((props, ref) => {
243254
[],
244255
)
245256

246-
const handleTouchStart: TouchEventHandler<HTMLCanvasElement> =
247-
useCallback(() => {
248-
dragRef.current = true
249-
mxRef.current = undefined
250-
myRef.current = undefined
251-
setDrag(true)
252-
}, [])
257+
const handleTouchStart: TouchEventHandler<HTMLCanvasElement> = useCallback(
258+
(e) => {
259+
if (e.touches.length === 2) {
260+
// Start pinch-to-zoom
261+
pinchRef.current = true
262+
dragRef.current = false
263+
setDrag(false)
264+
const dx = e.touches[0].pageX - e.touches[1].pageX
265+
const dy = e.touches[0].pageY - e.touches[1].pageY
266+
pinchStartDistRef.current = Math.sqrt(dx * dx + dy * dy)
267+
pinchStartScaleRef.current = scale
268+
} else {
269+
dragRef.current = true
270+
mxRef.current = undefined
271+
myRef.current = undefined
272+
setDrag(true)
273+
}
274+
},
275+
[scale],
276+
)
253277

254278
const handleKeyDown: KeyboardEventHandler<HTMLCanvasElement> = useCallback(
255279
(e) => {
@@ -323,6 +347,29 @@ const AvatarEditor = forwardRef<AvatarEditorRef, Props>((props, ref) => {
323347
coreRef.current.paint(context)
324348

325349
const handleDocumentMouseMove = (e: MouseEvent | TouchEvent) => {
350+
// Handle pinch-to-zoom (2 finger touch)
351+
if ('touches' in e && e.touches.length === 2) {
352+
if (e.cancelable) e.preventDefault()
353+
const dx = e.touches[0].pageX - e.touches[1].pageX
354+
const dy = e.touches[0].pageY - e.touches[1].pageY
355+
const dist = Math.sqrt(dx * dx + dy * dy)
356+
357+
if (!pinchRef.current) {
358+
// Start pinch (e.g. second finger added mid-drag)
359+
pinchRef.current = true
360+
dragRef.current = false
361+
setDrag(false)
362+
pinchStartDistRef.current = dist
363+
pinchStartScaleRef.current = scaleRef.current
364+
return
365+
}
366+
367+
const ratio = dist / pinchStartDistRef.current
368+
const newScale = Math.max(0.1, pinchStartScaleRef.current * ratio)
369+
onRequestScaleChangeRef.current?.(newScale)
370+
return
371+
}
372+
326373
if (!dragRef.current) {
327374
return
328375
}
@@ -362,24 +409,42 @@ const AvatarEditor = forwardRef<AvatarEditorRef, Props>((props, ref) => {
362409
}
363410

364411
const handleDocumentMouseUp = () => {
412+
if (pinchRef.current) {
413+
pinchRef.current = false
414+
}
365415
if (dragRef.current) {
366416
dragRef.current = false
367417
setDrag(false)
368418
onMouseUpRef.current?.()
369419
}
370420
}
371421

422+
const handleWheel = (e: WheelEvent) => {
423+
if (!enableWheelZoomRef.current || !onRequestScaleChangeRef.current)
424+
return
425+
e.preventDefault()
426+
427+
// ctrlKey is set for trackpad pinch gestures; use finer sensitivity
428+
const sensitivity = e.ctrlKey ? 0.01 : 0.002
429+
const delta = -e.deltaY * sensitivity
430+
const currentScale = scaleRef.current
431+
onRequestScaleChangeRef.current(Math.max(0.1, currentScale + delta))
432+
}
433+
434+
const canvasEl = canvas.current
372435
const options = isPassiveSupported() ? { passive: false } : false
373436
document.addEventListener('mousemove', handleDocumentMouseMove, options)
374437
document.addEventListener('mouseup', handleDocumentMouseUp, options)
375438
document.addEventListener('touchmove', handleDocumentMouseMove, options)
376439
document.addEventListener('touchend', handleDocumentMouseUp, options)
440+
canvasEl?.addEventListener('wheel', handleWheel, { passive: false })
377441

378442
return () => {
379443
document.removeEventListener('mousemove', handleDocumentMouseMove, false)
380444
document.removeEventListener('mouseup', handleDocumentMouseUp, false)
381445
document.removeEventListener('touchmove', handleDocumentMouseMove, false)
382446
document.removeEventListener('touchend', handleDocumentMouseUp, false)
447+
canvasEl?.removeEventListener('wheel', handleWheel)
383448
}
384449
}, [])
385450

106 KB
Loading
121 KB
Loading
4.5 KB
Loading
71.7 KB
Loading

packages/lib/tests/canvas.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,111 @@ test('exported image has no color overlay', async ({ page }) => {
8888
expect(await previewImg.screenshot()).toMatchSnapshot()
8989
})
9090

91+
test('canvas zooms in on wheel scroll up', async ({ page }) => {
92+
await page.goto('/')
93+
await page.waitForSelector('canvas')
94+
await page.waitForTimeout(500)
95+
96+
const canvas = page.locator('canvas')
97+
98+
// Wheel scroll up (negative deltaY) should zoom in
99+
await canvas.dispatchEvent('wheel', {
100+
deltaY: -500,
101+
clientX: 150,
102+
clientY: 150,
103+
})
104+
await page.waitForTimeout(200)
105+
expect(await canvas.screenshot()).toMatchSnapshot()
106+
})
107+
108+
test('canvas zooms out on wheel scroll down', async ({ page }) => {
109+
await page.goto('/')
110+
await page.waitForSelector('canvas')
111+
await page.waitForTimeout(500)
112+
113+
const canvas = page.locator('canvas')
114+
115+
// First zoom in, then zoom out past the initial level
116+
await canvas.dispatchEvent('wheel', {
117+
deltaY: -500,
118+
clientX: 150,
119+
clientY: 150,
120+
})
121+
await page.waitForTimeout(100)
122+
await canvas.dispatchEvent('wheel', {
123+
deltaY: 1000,
124+
clientX: 150,
125+
clientY: 150,
126+
})
127+
await page.waitForTimeout(200)
128+
expect(await canvas.screenshot()).toMatchSnapshot()
129+
})
130+
131+
test('canvas zooms on trackpad pinch gesture (ctrlKey+wheel)', async ({
132+
page,
133+
}) => {
134+
await page.goto('/')
135+
await page.waitForSelector('canvas')
136+
await page.waitForTimeout(500)
137+
138+
const canvas = page.locator('canvas')
139+
140+
// Trackpad pinch fires as wheel events with ctrlKey: true
141+
await canvas.dispatchEvent('wheel', {
142+
deltaY: -50,
143+
ctrlKey: true,
144+
clientX: 150,
145+
clientY: 150,
146+
})
147+
await page.waitForTimeout(200)
148+
expect(await canvas.screenshot()).toMatchSnapshot()
149+
})
150+
151+
test('canvas zooms via touch pinch gesture', async ({ page }) => {
152+
await page.goto('/')
153+
await page.waitForSelector('canvas')
154+
await page.waitForTimeout(500)
155+
156+
const canvas = page.locator('canvas')
157+
const box = await canvas.boundingBox()
158+
if (!box) throw new Error('canvas not found')
159+
160+
const cx = box.x + box.width / 2
161+
const cy = box.y + box.height / 2
162+
163+
// Simulate two-finger pinch spread using CDP Touch events
164+
const client = await page.context().newCDPSession(page)
165+
166+
// Touch down with two fingers close together
167+
await client.send('Input.dispatchTouchEvent', {
168+
type: 'touchStart',
169+
touchPoints: [
170+
{ x: cx - 20, y: cy },
171+
{ x: cx + 20, y: cy },
172+
],
173+
})
174+
175+
// Spread fingers apart (zoom in)
176+
for (let i = 1; i <= 5; i++) {
177+
await client.send('Input.dispatchTouchEvent', {
178+
type: 'touchMove',
179+
touchPoints: [
180+
{ x: cx - 20 - i * 15, y: cy },
181+
{ x: cx + 20 + i * 15, y: cy },
182+
],
183+
})
184+
}
185+
186+
// Release
187+
await client.send('Input.dispatchTouchEvent', {
188+
type: 'touchEnd',
189+
touchPoints: [],
190+
})
191+
192+
await page.waitForTimeout(200)
193+
expect(await canvas.screenshot()).toMatchSnapshot()
194+
})
195+
91196
test('canvas is keyboard accessible', async ({ page }) => {
92197
await page.goto('/')
93198
await page.waitForSelector('canvas')

0 commit comments

Comments
 (0)