@@ -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} )
0 commit comments