Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ const App = () => {
borderColor={hexToRgba(state.borderColor)}
onPositionChange={(position: Position) => update({ position })}
onRequestScaleChange={(scale: number) => update({ scale })}
enableWheelZoom
/>
<input {...getInputProps()} />
<span className="dropzone-hint">drop image here</span>
Expand Down
81 changes: 73 additions & 8 deletions packages/lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -70,6 +71,7 @@ const AvatarEditor = forwardRef<AvatarEditorRef, Props>((props, ref) => {
onMouseMove,
onPositionChange,
onRequestScaleChange,
enableWheelZoom = false,
borderColor,
style,
keyboardStep = 1,
Expand Down Expand Up @@ -102,14 +104,23 @@ const AvatarEditor = forwardRef<AvatarEditorRef, Props>((props, ref) => {
const mxRef = useRef<number | undefined>(undefined)
const myRef = useRef<number | undefined>(undefined)

// Pinch-to-zoom state
const pinchRef = useRef(false)
const pinchStartDistRef = useRef<number>(0)
const pinchStartScaleRef = useRef<number>(1)

// Keep state for `drag` and `loading` to trigger re-renders
const [drag, setDrag] = useState(false)
const [loading, setLoading] = useState(false)
const [imageState, setImageState] = useState<ImageState>(
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)
Expand Down Expand Up @@ -243,13 +254,26 @@ const AvatarEditor = forwardRef<AvatarEditorRef, Props>((props, ref) => {
[],
)

const handleTouchStart: TouchEventHandler<HTMLCanvasElement> =
useCallback(() => {
dragRef.current = true
mxRef.current = undefined
myRef.current = undefined
setDrag(true)
}, [])
const handleTouchStart: TouchEventHandler<HTMLCanvasElement> = 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<HTMLCanvasElement> = useCallback(
(e) => {
Expand Down Expand Up @@ -323,6 +347,29 @@ const AvatarEditor = forwardRef<AvatarEditorRef, Props>((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
}
Expand Down Expand Up @@ -362,24 +409,42 @@ const AvatarEditor = forwardRef<AvatarEditorRef, Props>((props, ref) => {
}

const handleDocumentMouseUp = () => {
if (pinchRef.current) {
pinchRef.current = false
}
if (dragRef.current) {
dragRef.current = false
setDrag(false)
onMouseUpRef.current?.()
}
}

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)
}
}, [])

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
105 changes: 105 additions & 0 deletions packages/lib/tests/canvas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading