diff --git a/packages/demo/src/App.tsx b/packages/demo/src/App.tsx
index 2ecce8f..58056ff 100644
--- a/packages/demo/src/App.tsx
+++ b/packages/demo/src/App.tsx
@@ -141,6 +141,7 @@ const App = () => {
borderColor={hexToRgba(state.borderColor)}
onPositionChange={(position: Position) => update({ position })}
onRequestScaleChange={(scale: number) => update({ scale })}
+ enableWheelZoom
/>
drop image here
diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts
index 0dafd59..86b51e6 100644
--- a/packages/lib/src/index.ts
+++ b/packages/lib/src/index.ts
@@ -33,6 +33,7 @@ export interface Props extends AvatarEditorConfig {
onMouseMove?: (e: TouchEvent | MouseEvent) => void
onPositionChange?: (position: Position) => void
onRequestScaleChange?: (scale: number) => void
+ enableWheelZoom?: boolean
}
export type { Position, ImageState }
@@ -70,6 +71,7 @@ const AvatarEditor = forwardRef((props, ref) => {
onMouseMove,
onPositionChange,
onRequestScaleChange,
+ enableWheelZoom = false,
borderColor,
style,
keyboardStep = 1,
@@ -102,6 +104,11 @@ const AvatarEditor = forwardRef((props, ref) => {
const mxRef = useRef(undefined)
const myRef = useRef(undefined)
+ // Pinch-to-zoom state
+ const pinchRef = useRef(false)
+ const pinchStartDistRef = useRef(0)
+ const pinchStartScaleRef = useRef(1)
+
// Keep state for `drag` and `loading` to trigger re-renders
const [drag, setDrag] = useState(false)
const [loading, setLoading] = useState(false)
@@ -109,7 +116,11 @@ const AvatarEditor = forwardRef((props, ref) => {
coreRef.current.getImageState(),
)
- // Store latest callback props in refs so document handlers always call current versions
+ // Store latest prop values in refs so document handlers always have current versions
+ const scaleRef = useRef(scale)
+ scaleRef.current = scale
+ const enableWheelZoomRef = useRef(enableWheelZoom)
+ enableWheelZoomRef.current = enableWheelZoom
const onMouseUpRef = useRef(onMouseUp)
onMouseUpRef.current = onMouseUp
const onMouseMoveRef = useRef(onMouseMove)
@@ -243,13 +254,26 @@ const AvatarEditor = forwardRef((props, ref) => {
[],
)
- const handleTouchStart: TouchEventHandler =
- useCallback(() => {
- dragRef.current = true
- mxRef.current = undefined
- myRef.current = undefined
- setDrag(true)
- }, [])
+ const handleTouchStart: TouchEventHandler = useCallback(
+ (e) => {
+ if (e.touches.length === 2) {
+ // Start pinch-to-zoom
+ pinchRef.current = true
+ dragRef.current = false
+ setDrag(false)
+ const dx = e.touches[0].pageX - e.touches[1].pageX
+ const dy = e.touches[0].pageY - e.touches[1].pageY
+ pinchStartDistRef.current = Math.sqrt(dx * dx + dy * dy)
+ pinchStartScaleRef.current = scale
+ } else {
+ dragRef.current = true
+ mxRef.current = undefined
+ myRef.current = undefined
+ setDrag(true)
+ }
+ },
+ [scale],
+ )
const handleKeyDown: KeyboardEventHandler = useCallback(
(e) => {
@@ -323,6 +347,29 @@ const AvatarEditor = forwardRef((props, ref) => {
coreRef.current.paint(context)
const handleDocumentMouseMove = (e: MouseEvent | TouchEvent) => {
+ // Handle pinch-to-zoom (2 finger touch)
+ if ('touches' in e && e.touches.length === 2) {
+ if (e.cancelable) e.preventDefault()
+ const dx = e.touches[0].pageX - e.touches[1].pageX
+ const dy = e.touches[0].pageY - e.touches[1].pageY
+ const dist = Math.sqrt(dx * dx + dy * dy)
+
+ if (!pinchRef.current) {
+ // Start pinch (e.g. second finger added mid-drag)
+ pinchRef.current = true
+ dragRef.current = false
+ setDrag(false)
+ pinchStartDistRef.current = dist
+ pinchStartScaleRef.current = scaleRef.current
+ return
+ }
+
+ const ratio = dist / pinchStartDistRef.current
+ const newScale = Math.max(0.1, pinchStartScaleRef.current * ratio)
+ onRequestScaleChangeRef.current?.(newScale)
+ return
+ }
+
if (!dragRef.current) {
return
}
@@ -362,6 +409,9 @@ const AvatarEditor = forwardRef((props, ref) => {
}
const handleDocumentMouseUp = () => {
+ if (pinchRef.current) {
+ pinchRef.current = false
+ }
if (dragRef.current) {
dragRef.current = false
setDrag(false)
@@ -369,17 +419,32 @@ const AvatarEditor = forwardRef((props, ref) => {
}
}
+ const handleWheel = (e: WheelEvent) => {
+ if (!enableWheelZoomRef.current || !onRequestScaleChangeRef.current)
+ return
+ e.preventDefault()
+
+ // ctrlKey is set for trackpad pinch gestures; use finer sensitivity
+ const sensitivity = e.ctrlKey ? 0.01 : 0.002
+ const delta = -e.deltaY * sensitivity
+ const currentScale = scaleRef.current
+ onRequestScaleChangeRef.current(Math.max(0.1, currentScale + delta))
+ }
+
+ const canvasEl = canvas.current
const options = isPassiveSupported() ? { passive: false } : false
document.addEventListener('mousemove', handleDocumentMouseMove, options)
document.addEventListener('mouseup', handleDocumentMouseUp, options)
document.addEventListener('touchmove', handleDocumentMouseMove, options)
document.addEventListener('touchend', handleDocumentMouseUp, options)
+ canvasEl?.addEventListener('wheel', handleWheel, { passive: false })
return () => {
document.removeEventListener('mousemove', handleDocumentMouseMove, false)
document.removeEventListener('mouseup', handleDocumentMouseUp, false)
document.removeEventListener('touchmove', handleDocumentMouseMove, false)
document.removeEventListener('touchend', handleDocumentMouseUp, false)
+ canvasEl?.removeEventListener('wheel', handleWheel)
}
}, [])
diff --git a/packages/lib/tests/__screenshots__/canvas.test.ts/canvas-zooms-in-on-wheel-scroll-up-1.png b/packages/lib/tests/__screenshots__/canvas.test.ts/canvas-zooms-in-on-wheel-scroll-up-1.png
new file mode 100644
index 0000000..307d2cb
Binary files /dev/null and b/packages/lib/tests/__screenshots__/canvas.test.ts/canvas-zooms-in-on-wheel-scroll-up-1.png differ
diff --git a/packages/lib/tests/__screenshots__/canvas.test.ts/canvas-zooms-on-trackpad-pinch-gesture-ctrlKey-wheel-1.png b/packages/lib/tests/__screenshots__/canvas.test.ts/canvas-zooms-on-trackpad-pinch-gesture-ctrlKey-wheel-1.png
new file mode 100644
index 0000000..9a43b84
Binary files /dev/null and b/packages/lib/tests/__screenshots__/canvas.test.ts/canvas-zooms-on-trackpad-pinch-gesture-ctrlKey-wheel-1.png differ
diff --git a/packages/lib/tests/__screenshots__/canvas.test.ts/canvas-zooms-out-on-wheel-scroll-down-1.png b/packages/lib/tests/__screenshots__/canvas.test.ts/canvas-zooms-out-on-wheel-scroll-down-1.png
new file mode 100644
index 0000000..80980d2
Binary files /dev/null and b/packages/lib/tests/__screenshots__/canvas.test.ts/canvas-zooms-out-on-wheel-scroll-down-1.png differ
diff --git a/packages/lib/tests/__screenshots__/canvas.test.ts/canvas-zooms-via-touch-pinch-gesture-1.png b/packages/lib/tests/__screenshots__/canvas.test.ts/canvas-zooms-via-touch-pinch-gesture-1.png
new file mode 100644
index 0000000..19d8aef
Binary files /dev/null and b/packages/lib/tests/__screenshots__/canvas.test.ts/canvas-zooms-via-touch-pinch-gesture-1.png differ
diff --git a/packages/lib/tests/canvas.test.ts b/packages/lib/tests/canvas.test.ts
index f9e0ffc..3e20962 100644
--- a/packages/lib/tests/canvas.test.ts
+++ b/packages/lib/tests/canvas.test.ts
@@ -88,6 +88,111 @@ test('exported image has no color overlay', async ({ page }) => {
expect(await previewImg.screenshot()).toMatchSnapshot()
})
+test('canvas zooms in on wheel scroll up', async ({ page }) => {
+ await page.goto('/')
+ await page.waitForSelector('canvas')
+ await page.waitForTimeout(500)
+
+ const canvas = page.locator('canvas')
+
+ // Wheel scroll up (negative deltaY) should zoom in
+ await canvas.dispatchEvent('wheel', {
+ deltaY: -500,
+ clientX: 150,
+ clientY: 150,
+ })
+ await page.waitForTimeout(200)
+ expect(await canvas.screenshot()).toMatchSnapshot()
+})
+
+test('canvas zooms out on wheel scroll down', async ({ page }) => {
+ await page.goto('/')
+ await page.waitForSelector('canvas')
+ await page.waitForTimeout(500)
+
+ const canvas = page.locator('canvas')
+
+ // First zoom in, then zoom out past the initial level
+ await canvas.dispatchEvent('wheel', {
+ deltaY: -500,
+ clientX: 150,
+ clientY: 150,
+ })
+ await page.waitForTimeout(100)
+ await canvas.dispatchEvent('wheel', {
+ deltaY: 1000,
+ clientX: 150,
+ clientY: 150,
+ })
+ await page.waitForTimeout(200)
+ expect(await canvas.screenshot()).toMatchSnapshot()
+})
+
+test('canvas zooms on trackpad pinch gesture (ctrlKey+wheel)', async ({
+ page,
+}) => {
+ await page.goto('/')
+ await page.waitForSelector('canvas')
+ await page.waitForTimeout(500)
+
+ const canvas = page.locator('canvas')
+
+ // Trackpad pinch fires as wheel events with ctrlKey: true
+ await canvas.dispatchEvent('wheel', {
+ deltaY: -50,
+ ctrlKey: true,
+ clientX: 150,
+ clientY: 150,
+ })
+ await page.waitForTimeout(200)
+ expect(await canvas.screenshot()).toMatchSnapshot()
+})
+
+test('canvas zooms via touch pinch gesture', async ({ page }) => {
+ await page.goto('/')
+ await page.waitForSelector('canvas')
+ await page.waitForTimeout(500)
+
+ const canvas = page.locator('canvas')
+ const box = await canvas.boundingBox()
+ if (!box) throw new Error('canvas not found')
+
+ const cx = box.x + box.width / 2
+ const cy = box.y + box.height / 2
+
+ // Simulate two-finger pinch spread using CDP Touch events
+ const client = await page.context().newCDPSession(page)
+
+ // Touch down with two fingers close together
+ await client.send('Input.dispatchTouchEvent', {
+ type: 'touchStart',
+ touchPoints: [
+ { x: cx - 20, y: cy },
+ { x: cx + 20, y: cy },
+ ],
+ })
+
+ // Spread fingers apart (zoom in)
+ for (let i = 1; i <= 5; i++) {
+ await client.send('Input.dispatchTouchEvent', {
+ type: 'touchMove',
+ touchPoints: [
+ { x: cx - 20 - i * 15, y: cy },
+ { x: cx + 20 + i * 15, y: cy },
+ ],
+ })
+ }
+
+ // Release
+ await client.send('Input.dispatchTouchEvent', {
+ type: 'touchEnd',
+ touchPoints: [],
+ })
+
+ await page.waitForTimeout(200)
+ expect(await canvas.screenshot()).toMatchSnapshot()
+})
+
test('canvas is keyboard accessible', async ({ page }) => {
await page.goto('/')
await page.waitForSelector('canvas')