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')