@@ -15,6 +15,7 @@ const SHAPE_TYPE = {
1515 PEN : "pen" ,
1616 CIRCLE : "circle" ,
1717 ERASER : "eraser" ,
18+ IMAGE : 'image' ,
1819} ;
1920
2021export const Canvas = ( ) => {
@@ -82,6 +83,30 @@ export const Canvas = () => {
8283 }
8384 } ;
8485
86+ const handleImageUpload = ( file ) => {
87+ if ( ! file ) return ;
88+ const reader = new FileReader ( ) ;
89+ reader . onload = ( event ) => {
90+ const img = new Image ( ) ;
91+ img . onload = ( ) => {
92+ const newShape = {
93+ id : Date . now ( ) . toString ( ) ,
94+ type : SHAPE_TYPE . IMAGE ,
95+ image : img ,
96+ start : { x : 100 , y : 100 } ,
97+ end : { x : 100 + img . width , y : 100 + img . height } ,
98+ width : img . width ,
99+ height : img . height ,
100+ } ;
101+ setShapes ( ( prev ) => [ ...prev , newShape ] ) ;
102+ } ;
103+ img . src = event . target . result ;
104+ } ;
105+ reader . readAsDataURL ( file ) ;
106+ } ;
107+
108+
109+
85110 // --- Helpers ---
86111 const getWorldPoint = ( e ) => {
87112 const canvas = canvasRef . current ;
@@ -136,6 +161,14 @@ export const Canvas = () => {
136161 const maxY = Math . max ( ...shape . path . map ( p => p . y ) ) ;
137162 return { minX, minY, maxX, maxY, width : maxX - minX , height : maxY - minY } ;
138163 }
164+ if ( shape . type === SHAPE_TYPE . IMAGE ) {
165+ const minX = Math . min ( shape . start . x , shape . end . x ) ;
166+ const maxX = Math . max ( shape . start . x , shape . end . x ) ;
167+ const minY = Math . min ( shape . start . y , shape . end . y ) ;
168+ const maxY = Math . max ( shape . start . y , shape . end . y ) ;
169+ return { minX, minY, maxX, maxY, width : maxX - minX , height : maxY - minY } ;
170+ }
171+
139172
140173 return null ;
141174} , [ ] ) ;
@@ -192,7 +225,7 @@ export const Canvas = () => {
192225 if ( isSelected ) {
193226 // draw purple glow under the stroke for visibility
194227 ctx . save ( ) ;
195- ctx . lineWidth = shape . width + 4 ;
228+ ctx . lineWidth = shape . type === SHAPE_TYPE . IMAGE ? 4 : shape . width + 4 ;
196229 ctx . strokeStyle = "rgba(76,29,149,1)" ;
197230 switch ( shape . type ) {
198231 case SHAPE_TYPE . LINE :
@@ -245,7 +278,7 @@ export const Canvas = () => {
245278
246279 // then draw the actual shape on top
247280 ctx . strokeStyle = shape . color ;
248- ctx . lineWidth = shape . width ;
281+ ctx . lineWidth = shape . type === SHAPE_TYPE . IMAGE ? 1 : shape . width ;
249282 }
250283
251284 switch ( shape . type ) {
@@ -517,6 +550,15 @@ export const Canvas = () => {
517550 ctx . globalAlpha = 1 ;
518551 }
519552 break ;
553+ case SHAPE_TYPE . IMAGE : {
554+ const { image, start, end } = shape ;
555+ if ( image ) {
556+ const width = end . x - start . x ;
557+ const height = end . y - start . y ;
558+ ctx . drawImage ( image , start . x , start . y , width , height ) ;
559+ }
560+ break ;
561+ }
520562 default :
521563 break ;
522564 }
@@ -547,12 +589,15 @@ export const Canvas = () => {
547589 const ctx = canvas ?. getContext ( "2d" ) ;
548590 if ( ! ctx ) return ;
549591
550- // clear and reset transform
551- ctx . setTransform ( 1 , 0 , 0 , 1 , 0 , 0 ) ;
552- ctx . clearRect ( 0 , 0 , canvas . width , canvas . height ) ;
592+ // clear and reset transform
593+ ctx . setTransform ( 1 , 0 , 0 , 1 , 0 , 0 ) ;
594+ ctx . clearRect ( 0 , 0 , canvas . width , canvas . height ) ;
595+ ctx . globalCompositeOperation = "source-over" ;
596+ ctx . globalAlpha = 1 ;
597+ ctx . shadowBlur = 0 ;
553598
554- // apply pan & zoom
555- ctx . setTransform ( 1 , 0 , 0 , 1 , offset . x , offset . y ) ;
599+ // apply pan & zoom
600+ ctx . setTransform ( 1 , 0 , 0 , 1 , offset . x , offset . y ) ;
556601 ctx . scale ( scale , scale ) ;
557602
558603 // draw non-selected shapes first
@@ -622,18 +667,29 @@ export const Canvas = () => {
622667 if ( handle && hitShape && hitShape . id === selectedShapeId ) {
623668 // begin resize
624669 const origShape = JSON . parse ( JSON . stringify ( hitShape ) ) ;
670+ if ( hitShape . type === SHAPE_TYPE . IMAGE ) origShape . image = hitShape . image ;
625671 const origBBox = getShapeBBox ( origShape ) ;
626672 manipulationMode . current = { mode : 'resize' , dir : handle . dir , origShape, origBBox } ;
627673 setIsDrawing ( true ) ;
628674 return ;
629675 }
630676
631677 if ( hitShape ) {
678+ try {
679+ const _ctx = canvasRef . current ?. getContext ( '2d' ) ;
680+ if ( _ctx ) {
681+ _ctx . globalCompositeOperation = 'source-over' ;
682+ _ctx . globalAlpha = 1 ;
683+ _ctx . shadowBlur = 0 ;
684+ }
685+ } catch ( err ) {
686+ console . debug ( '[canvas] composite reset failed' , err ) ;
687+ }
632688 setSelectedShapeId ( hitShape . id ) ;
633689 setIsDrawing ( true ) ;
634- manipulationMode . current = { mode : "move" } ;
690+ manipulationMode . current = { mode : "pending- move" } ;
635691 setActiveColor ( hitShape . color ) ;
636- setStrokeWidth ( hitShape . width ) ;
692+ if ( hitShape . type === SHAPE_TYPE . PEN ) setStrokeWidth ( hitShape . width ) ;
637693 } else {
638694 setSelectedShapeId ( null ) ;
639695 setIsDrawing ( false ) ;
@@ -665,7 +721,7 @@ export const Canvas = () => {
665721
666722
667723 // creation tools
668- if ( Object . values ( SHAPE_TYPE ) . includes ( activeTool ) || activeTool . startsWith ( 'brush-' ) ) {
724+ if ( ( Object . values ( SHAPE_TYPE ) . includes ( activeTool ) || activeTool . startsWith ( 'brush-' ) ) && activeTool !== SHAPE_TYPE . IMAGE ) {
669725 setSelectedShapeId ( null ) ;
670726 setIsDrawing ( true ) ;
671727 manipulationMode . current = { mode : "create" } ;
@@ -686,6 +742,15 @@ export const Canvas = () => {
686742 newShape . brush = brushType || "solid" ;
687743 newShape . _seed = Math . floor ( Math . random ( ) * 0xffffffff ) ;
688744 }
745+ if ( activeTool === SHAPE_TYPE . IMAGE ) {
746+ const hitShape = shapes . slice ( ) . reverse ( ) . find ( ( shape ) => isPointInShape ( worldPoint , shape ) ) ;
747+ if ( hitShape && hitShape . type === SHAPE_TYPE . IMAGE ) {
748+ // Just select, don't draw a new one
749+ setSelectedShapeId ( hitShape . id ) ;
750+ setIsDrawing ( false ) ;
751+ return ;
752+ }
753+ }
689754 if ( activeTool === SHAPE_TYPE . CIRCLE ) {
690755 newShape . radius = 0 ;
691756 }
@@ -702,8 +767,13 @@ export const Canvas = () => {
702767 if ( ! shape ) return false ;
703768 const bbox = getShapeBBox ( shape ) ;
704769 if ( ! bbox ) return false ;
705- const tol = shape . width + 6 ;
706- return ( point . x >= bbox . minX - tol && point . x <= bbox . maxX + tol && point . y >= bbox . minY - tol && point . y <= bbox . maxY + tol ) ;
770+ const tol = shape . type === SHAPE_TYPE . IMAGE ? 8 : ( shape . width || 0 ) + 6 ;
771+ return (
772+ point . x >= bbox . minX - tol &&
773+ point . x <= bbox . maxX + tol &&
774+ point . y >= bbox . minY - tol &&
775+ point . y <= bbox . maxY + tol
776+ ) ;
707777 } ;
708778
709779 const draw = ( e ) => {
@@ -722,6 +792,24 @@ export const Canvas = () => {
722792 }
723793
724794 if ( ! isDrawing ) return ;
795+ if (
796+ activeTool === 'select' &&
797+ selectedShapeId &&
798+ manipulationMode . current &&
799+ manipulationMode . current . mode === 'pending-move'
800+ ) {
801+ const dx0 = worldPoint . x - pointerStart . current . x ;
802+ const dy0 = worldPoint . y - pointerStart . current . y ;
803+ const distSq0 = dx0 * dx0 + dy0 * dy0 ;
804+ const threshold = 4 * 4 ; // squared threshold in world coords
805+ if ( distSq0 > threshold ) {
806+ // begin move: set pointerStart so subsequent deltas work from here
807+ manipulationMode . current . mode = 'move' ;
808+ pointerStart . current = worldPoint ;
809+ } else {
810+ return ;
811+ }
812+ }
725813
726814 // MOVE
727815 if ( activeTool === 'select' && selectedShapeId && manipulationMode . current && manipulationMode . current . mode === 'move' ) {
@@ -753,8 +841,9 @@ export const Canvas = () => {
753841 const shapeIndex = shapes . findIndex ( ( s ) => s . id === selectedShapeId ) ;
754842 if ( shapeIndex === - 1 ) return ;
755843
756- const newShapes = [ ...shapes ] ;
757- const sh = JSON . parse ( JSON . stringify ( origShape ) ) ;
844+ const newShapes = [ ...shapes ] ;
845+ const sh = JSON . parse ( JSON . stringify ( origShape ) ) ;
846+ if ( origShape && origShape . type === SHAPE_TYPE . IMAGE ) sh . image = origShape . image ;
758847
759848 // We'll compute a new bounding box keeping the opposite corner fixed depending on dir
760849 let { minX, minY, maxX, maxY } = origBBox ;
@@ -873,14 +962,15 @@ export const Canvas = () => {
873962 setIsPointerDown ( false ) ;
874963 if ( ! isDrawing ) return ;
875964 setIsDrawing ( false ) ;
965+ const prevMode = manipulationMode . current ?. mode ;
876966 newShapeId . current = null ;
877967 manipulationMode . current = null ;
878- if ( manipulationMode . current ?. mode === "erase" ) {
879- const ctx = canvasRef . current . getContext ( "2d" ) ;
880- ctx . globalCompositeOperation = "source-over" ;
881- }
968+ if ( prevMode === "erase" ) {
969+ const ctx = canvasRef . current ? .getContext ( "2d" ) ;
970+ if ( ctx ) ctx . globalCompositeOperation = "source-over" ;
971+ }
882972
883- } ;
973+ } ;
884974
885975 // delete
886976 const handleDeleteSelectedShape = useCallback ( ( ) => {
@@ -1053,6 +1143,7 @@ export const Canvas = () => {
10531143 onToolChange = { handleToolChange }
10541144 onClear = { handleClear }
10551145 onExport = { handleExport }
1146+ onImageUpload = { handleImageUpload }
10561147 />
10571148
10581149 { joined ? (
0 commit comments