Skip to content

Commit 8ddae23

Browse files
committed
make wheel zoom opt-in via enableWheelZoom prop
Wheel/trackpad zoom is now disabled by default and gated behind the enableWheelZoom prop (default: false). Pinch-to-zoom with two fingers remains always enabled. Adds tests for both pinch and wheel zoom.
1 parent fd5b8d7 commit 8ddae23

3 files changed

Lines changed: 220 additions & 1 deletion

File tree

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/__tests__/AvatarEditor.test.tsx

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,4 +547,218 @@ describe('AvatarEditor', () => {
547547
fireEvent.keyDown(canvas, { key: 'ArrowUp' })
548548
})
549549
})
550+
551+
// -------------------------------------------------------
552+
// 14. Pinch-to-zoom
553+
// -------------------------------------------------------
554+
describe('pinch-to-zoom', () => {
555+
// Helper to create a TouchEvent with custom touches (jsdom lacks Touch constructor)
556+
function makeTouchEvent(
557+
type: string,
558+
touches: Array<{ pageX: number; pageY: number }>,
559+
opts: EventInit = {},
560+
) {
561+
const event = new Event(type, { bubbles: true, cancelable: true, ...opts })
562+
Object.defineProperty(event, 'touches', { value: touches })
563+
if (type === 'touchmove') {
564+
Object.defineProperty(event, 'targetTouches', { value: touches })
565+
}
566+
return event
567+
}
568+
569+
it('calls onRequestScaleChange when pinching with two fingers', () => {
570+
const onRequestScaleChange = vi.fn()
571+
const { container } = render(
572+
<AvatarEditor
573+
width={200}
574+
height={200}
575+
scale={1}
576+
onRequestScaleChange={onRequestScaleChange}
577+
/>,
578+
)
579+
const canvas = container.querySelector('canvas')!
580+
581+
// Start two-finger touch
582+
fireEvent.touchStart(canvas, {
583+
touches: [
584+
{ pageX: 100, pageY: 100 },
585+
{ pageX: 200, pageY: 100 },
586+
],
587+
})
588+
589+
// Move fingers apart (zoom in) - initial distance 100, new distance 200
590+
document.dispatchEvent(
591+
makeTouchEvent('touchmove', [
592+
{ pageX: 50, pageY: 100 },
593+
{ pageX: 250, pageY: 100 },
594+
]),
595+
)
596+
597+
expect(onRequestScaleChange).toHaveBeenCalled()
598+
const newScale = onRequestScaleChange.mock.calls[0][0]
599+
expect(newScale).toBeGreaterThan(1)
600+
})
601+
602+
it('starts pinch when second finger is added during drag', () => {
603+
const onRequestScaleChange = vi.fn()
604+
const { container } = render(
605+
<AvatarEditor
606+
width={200}
607+
height={200}
608+
scale={1}
609+
onRequestScaleChange={onRequestScaleChange}
610+
/>,
611+
)
612+
const canvas = container.querySelector('canvas')!
613+
614+
// Start single-finger drag
615+
fireEvent.touchStart(canvas, {
616+
touches: [{ pageX: 100, pageY: 100 }],
617+
})
618+
619+
// Second finger appears during touchmove - should initialize pinch
620+
document.dispatchEvent(
621+
makeTouchEvent('touchmove', [
622+
{ pageX: 100, pageY: 100 },
623+
{ pageX: 200, pageY: 100 },
624+
]),
625+
)
626+
627+
// Now move fingers apart - should trigger scale change
628+
document.dispatchEvent(
629+
makeTouchEvent('touchmove', [
630+
{ pageX: 50, pageY: 100 },
631+
{ pageX: 250, pageY: 100 },
632+
]),
633+
)
634+
635+
expect(onRequestScaleChange).toHaveBeenCalled()
636+
})
637+
638+
it('resets pinch state on touchend', () => {
639+
const onRequestScaleChange = vi.fn()
640+
const { container } = render(
641+
<AvatarEditor
642+
width={200}
643+
height={200}
644+
scale={1}
645+
onRequestScaleChange={onRequestScaleChange}
646+
/>,
647+
)
648+
const canvas = container.querySelector('canvas')!
649+
650+
// Start pinch
651+
fireEvent.touchStart(canvas, {
652+
touches: [
653+
{ pageX: 100, pageY: 100 },
654+
{ pageX: 200, pageY: 100 },
655+
],
656+
})
657+
658+
// End touch
659+
document.dispatchEvent(new Event('touchend', { bubbles: true }))
660+
661+
onRequestScaleChange.mockClear()
662+
663+
// Single-touch drag should not trigger scale change
664+
fireEvent.touchStart(canvas, {
665+
touches: [{ pageX: 100, pageY: 100 }],
666+
})
667+
668+
document.dispatchEvent(
669+
makeTouchEvent('touchmove', [{ pageX: 110, pageY: 100 }]),
670+
)
671+
672+
expect(onRequestScaleChange).not.toHaveBeenCalled()
673+
})
674+
})
675+
676+
// -------------------------------------------------------
677+
// 15. Wheel zoom
678+
// -------------------------------------------------------
679+
describe('wheel zoom', () => {
680+
it('does not call onRequestScaleChange on wheel by default', () => {
681+
const onRequestScaleChange = vi.fn()
682+
const { container } = render(
683+
<AvatarEditor
684+
width={200}
685+
height={200}
686+
scale={1}
687+
onRequestScaleChange={onRequestScaleChange}
688+
/>,
689+
)
690+
const canvas = container.querySelector('canvas')!
691+
692+
canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, bubbles: true }))
693+
694+
expect(onRequestScaleChange).not.toHaveBeenCalled()
695+
})
696+
697+
it('calls onRequestScaleChange on wheel when enableWheelZoom is true', () => {
698+
const onRequestScaleChange = vi.fn()
699+
const { container } = render(
700+
<AvatarEditor
701+
width={200}
702+
height={200}
703+
scale={1}
704+
onRequestScaleChange={onRequestScaleChange}
705+
enableWheelZoom
706+
/>,
707+
)
708+
const canvas = container.querySelector('canvas')!
709+
710+
canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, bubbles: true }))
711+
712+
expect(onRequestScaleChange).toHaveBeenCalled()
713+
const newScale = onRequestScaleChange.mock.calls[0][0]
714+
expect(newScale).toBeGreaterThan(1)
715+
})
716+
717+
it('zooms out on positive deltaY', () => {
718+
const onRequestScaleChange = vi.fn()
719+
const { container } = render(
720+
<AvatarEditor
721+
width={200}
722+
height={200}
723+
scale={1.5}
724+
onRequestScaleChange={onRequestScaleChange}
725+
enableWheelZoom
726+
/>,
727+
)
728+
const canvas = container.querySelector('canvas')!
729+
730+
canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: 100, bubbles: true }))
731+
732+
expect(onRequestScaleChange).toHaveBeenCalled()
733+
const newScale = onRequestScaleChange.mock.calls[0][0]
734+
expect(newScale).toBeLessThan(1.5)
735+
})
736+
737+
it('does not zoom below 0.1', () => {
738+
const onRequestScaleChange = vi.fn()
739+
const { container } = render(
740+
<AvatarEditor
741+
width={200}
742+
height={200}
743+
scale={0.1}
744+
onRequestScaleChange={onRequestScaleChange}
745+
enableWheelZoom
746+
/>,
747+
)
748+
const canvas = container.querySelector('canvas')!
749+
750+
canvas.dispatchEvent(new WheelEvent('wheel', { deltaY: 99999, bubbles: true }))
751+
752+
expect(onRequestScaleChange).toHaveBeenCalled()
753+
const newScale = onRequestScaleChange.mock.calls[0][0]
754+
expect(newScale).toBeGreaterThanOrEqual(0.1)
755+
})
756+
757+
it('accepts enableWheelZoom prop without error', () => {
758+
const { container } = render(
759+
<AvatarEditor width={200} height={200} enableWheelZoom />,
760+
)
761+
expect(container.querySelector('canvas')).toBeInTheDocument()
762+
})
763+
})
550764
})

packages/lib/src/index.ts

Lines changed: 5 additions & 1 deletion
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,
@@ -117,6 +119,8 @@ const AvatarEditor = forwardRef<AvatarEditorRef, Props>((props, ref) => {
117119
// Store latest prop values in refs so document handlers always have current versions
118120
const scaleRef = useRef(scale)
119121
scaleRef.current = scale
122+
const enableWheelZoomRef = useRef(enableWheelZoom)
123+
enableWheelZoomRef.current = enableWheelZoom
120124
const onMouseUpRef = useRef(onMouseUp)
121125
onMouseUpRef.current = onMouseUp
122126
const onMouseMoveRef = useRef(onMouseMove)
@@ -417,7 +421,7 @@ const AvatarEditor = forwardRef<AvatarEditorRef, Props>((props, ref) => {
417421
}
418422

419423
const handleWheel = (e: WheelEvent) => {
420-
if (!onRequestScaleChangeRef.current) return
424+
if (!enableWheelZoomRef.current || !onRequestScaleChangeRef.current) return
421425
e.preventDefault()
422426

423427
// ctrlKey is set for trackpad pinch gestures; use finer sensitivity

0 commit comments

Comments
 (0)