@@ -15,6 +15,7 @@ const SHAPE_TYPE = {
1515 PEN : "pen" ,
1616 CIRCLE : "circle" ,
1717 ERASER : "eraser" ,
18+ IMAGE : 'image' ,
1819} ;
1920
2021export const Canvas = ( ) => {
@@ -55,6 +56,30 @@ export const Canvas = () => {
5556 // placeholder
5657 } ;
5758
59+ const handleImageUpload = ( file ) => {
60+ if ( ! file ) return ;
61+ const reader = new FileReader ( ) ;
62+ reader . onload = ( event ) => {
63+ const img = new Image ( ) ;
64+ img . onload = ( ) => {
65+ const newShape = {
66+ id : Date . now ( ) . toString ( ) ,
67+ type : SHAPE_TYPE . IMAGE ,
68+ image : img ,
69+ start : { x : 100 , y : 100 } ,
70+ end : { x : 100 + img . width , y : 100 + img . height } ,
71+ width : img . width ,
72+ height : img . height ,
73+ } ;
74+ setShapes ( ( prev ) => [ ...prev , newShape ] ) ;
75+ } ;
76+ img . src = event . target . result ;
77+ } ;
78+ reader . readAsDataURL ( file ) ;
79+ } ;
80+
81+
82+
5883 // --- Helpers ---
5984 const getWorldPoint = ( e ) => {
6085 const canvas = canvasRef . current ;
@@ -109,6 +134,14 @@ export const Canvas = () => {
109134 const maxY = Math . max ( ...shape . path . map ( p => p . y ) ) ;
110135 return { minX, minY, maxX, maxY, width : maxX - minX , height : maxY - minY } ;
111136 }
137+ if ( shape . type === SHAPE_TYPE . IMAGE ) {
138+ const minX = Math . min ( shape . start . x , shape . end . x ) ;
139+ const maxX = Math . max ( shape . start . x , shape . end . x ) ;
140+ const minY = Math . min ( shape . start . y , shape . end . y ) ;
141+ const maxY = Math . max ( shape . start . y , shape . end . y ) ;
142+ return { minX, minY, maxX, maxY, width : maxX - minX , height : maxY - minY } ;
143+ }
144+
112145
113146 return null ;
114147} , [ ] ) ;
@@ -165,7 +198,7 @@ export const Canvas = () => {
165198 if ( isSelected ) {
166199 // draw purple glow under the stroke for visibility
167200 ctx . save ( ) ;
168- ctx . lineWidth = shape . width + 4 ;
201+ ctx . lineWidth = shape . type === SHAPE_TYPE . IMAGE ? 4 : shape . width + 4 ;
169202 ctx . strokeStyle = "rgba(76,29,149,1)" ;
170203 switch ( shape . type ) {
171204 case SHAPE_TYPE . LINE :
@@ -218,7 +251,7 @@ export const Canvas = () => {
218251
219252 // then draw the actual shape on top
220253 ctx . strokeStyle = shape . color ;
221- ctx . lineWidth = shape . width ;
254+ ctx . lineWidth = shape . type === SHAPE_TYPE . IMAGE ? 1 : shape . width ;
222255 }
223256
224257 switch ( shape . type ) {
@@ -490,6 +523,15 @@ export const Canvas = () => {
490523 ctx . globalAlpha = 1 ;
491524 }
492525 break ;
526+ case SHAPE_TYPE . IMAGE : {
527+ const { image, start, end } = shape ;
528+ if ( image ) {
529+ const width = end . x - start . x ;
530+ const height = end . y - start . y ;
531+ ctx . drawImage ( image , start . x , start . y , width , height ) ;
532+ }
533+ break ;
534+ }
493535 default :
494536 break ;
495537 }
@@ -520,12 +562,15 @@ export const Canvas = () => {
520562 const ctx = canvas ?. getContext ( "2d" ) ;
521563 if ( ! ctx ) return ;
522564
523- // clear and reset transform
524- ctx . setTransform ( 1 , 0 , 0 , 1 , 0 , 0 ) ;
525- ctx . clearRect ( 0 , 0 , canvas . width , canvas . height ) ;
565+ // clear and reset transform
566+ ctx . setTransform ( 1 , 0 , 0 , 1 , 0 , 0 ) ;
567+ ctx . clearRect ( 0 , 0 , canvas . width , canvas . height ) ;
568+ ctx . globalCompositeOperation = "source-over" ;
569+ ctx . globalAlpha = 1 ;
570+ ctx . shadowBlur = 0 ;
526571
527- // apply pan & zoom
528- ctx . setTransform ( 1 , 0 , 0 , 1 , offset . x , offset . y ) ;
572+ // apply pan & zoom
573+ ctx . setTransform ( 1 , 0 , 0 , 1 , offset . x , offset . y ) ;
529574 ctx . scale ( scale , scale ) ;
530575
531576 // draw non-selected shapes first
@@ -595,18 +640,29 @@ export const Canvas = () => {
595640 if ( handle && hitShape && hitShape . id === selectedShapeId ) {
596641 // begin resize
597642 const origShape = JSON . parse ( JSON . stringify ( hitShape ) ) ;
643+ if ( hitShape . type === SHAPE_TYPE . IMAGE ) origShape . image = hitShape . image ;
598644 const origBBox = getShapeBBox ( origShape ) ;
599645 manipulationMode . current = { mode : 'resize' , dir : handle . dir , origShape, origBBox } ;
600646 setIsDrawing ( true ) ;
601647 return ;
602648 }
603649
604650 if ( hitShape ) {
651+ try {
652+ const _ctx = canvasRef . current ?. getContext ( '2d' ) ;
653+ if ( _ctx ) {
654+ _ctx . globalCompositeOperation = 'source-over' ;
655+ _ctx . globalAlpha = 1 ;
656+ _ctx . shadowBlur = 0 ;
657+ }
658+ } catch ( err ) {
659+ console . debug ( '[canvas] composite reset failed' , err ) ;
660+ }
605661 setSelectedShapeId ( hitShape . id ) ;
606662 setIsDrawing ( true ) ;
607- manipulationMode . current = { mode : "move" } ;
663+ manipulationMode . current = { mode : "pending- move" } ;
608664 setActiveColor ( hitShape . color ) ;
609- setStrokeWidth ( hitShape . width ) ;
665+ if ( hitShape . type === SHAPE_TYPE . PEN ) setStrokeWidth ( hitShape . width ) ;
610666 } else {
611667 setSelectedShapeId ( null ) ;
612668 setIsDrawing ( false ) ;
@@ -638,7 +694,7 @@ export const Canvas = () => {
638694
639695
640696 // creation tools
641- if ( Object . values ( SHAPE_TYPE ) . includes ( activeTool ) || activeTool . startsWith ( 'brush-' ) ) {
697+ if ( ( Object . values ( SHAPE_TYPE ) . includes ( activeTool ) || activeTool . startsWith ( 'brush-' ) ) && activeTool !== SHAPE_TYPE . IMAGE ) {
642698 setSelectedShapeId ( null ) ;
643699 setIsDrawing ( true ) ;
644700 manipulationMode . current = { mode : "create" } ;
@@ -659,6 +715,15 @@ export const Canvas = () => {
659715 newShape . brush = brushType || "solid" ;
660716 newShape . _seed = Math . floor ( Math . random ( ) * 0xffffffff ) ;
661717 }
718+ if ( activeTool === SHAPE_TYPE . IMAGE ) {
719+ const hitShape = shapes . slice ( ) . reverse ( ) . find ( ( shape ) => isPointInShape ( worldPoint , shape ) ) ;
720+ if ( hitShape && hitShape . type === SHAPE_TYPE . IMAGE ) {
721+ // Just select, don't draw a new one
722+ setSelectedShapeId ( hitShape . id ) ;
723+ setIsDrawing ( false ) ;
724+ return ;
725+ }
726+ }
662727 if ( activeTool === SHAPE_TYPE . CIRCLE ) {
663728 newShape . radius = 0 ;
664729 }
@@ -675,8 +740,13 @@ export const Canvas = () => {
675740 if ( ! shape ) return false ;
676741 const bbox = getShapeBBox ( shape ) ;
677742 if ( ! bbox ) return false ;
678- const tol = shape . width + 6 ;
679- return ( point . x >= bbox . minX - tol && point . x <= bbox . maxX + tol && point . y >= bbox . minY - tol && point . y <= bbox . maxY + tol ) ;
743+ const tol = shape . type === SHAPE_TYPE . IMAGE ? 8 : ( shape . width || 0 ) + 6 ;
744+ return (
745+ point . x >= bbox . minX - tol &&
746+ point . x <= bbox . maxX + tol &&
747+ point . y >= bbox . minY - tol &&
748+ point . y <= bbox . maxY + tol
749+ ) ;
680750 } ;
681751
682752 const draw = ( e ) => {
@@ -695,6 +765,24 @@ export const Canvas = () => {
695765 }
696766
697767 if ( ! isDrawing ) return ;
768+ if (
769+ activeTool === 'select' &&
770+ selectedShapeId &&
771+ manipulationMode . current &&
772+ manipulationMode . current . mode === 'pending-move'
773+ ) {
774+ const dx0 = worldPoint . x - pointerStart . current . x ;
775+ const dy0 = worldPoint . y - pointerStart . current . y ;
776+ const distSq0 = dx0 * dx0 + dy0 * dy0 ;
777+ const threshold = 4 * 4 ; // squared threshold in world coords
778+ if ( distSq0 > threshold ) {
779+ // begin move: set pointerStart so subsequent deltas work from here
780+ manipulationMode . current . mode = 'move' ;
781+ pointerStart . current = worldPoint ;
782+ } else {
783+ return ;
784+ }
785+ }
698786
699787 // MOVE
700788 if ( activeTool === 'select' && selectedShapeId && manipulationMode . current && manipulationMode . current . mode === 'move' ) {
@@ -726,8 +814,9 @@ export const Canvas = () => {
726814 const shapeIndex = shapes . findIndex ( ( s ) => s . id === selectedShapeId ) ;
727815 if ( shapeIndex === - 1 ) return ;
728816
729- const newShapes = [ ...shapes ] ;
730- const sh = JSON . parse ( JSON . stringify ( origShape ) ) ;
817+ const newShapes = [ ...shapes ] ;
818+ const sh = JSON . parse ( JSON . stringify ( origShape ) ) ;
819+ if ( origShape && origShape . type === SHAPE_TYPE . IMAGE ) sh . image = origShape . image ;
731820
732821 // We'll compute a new bounding box keeping the opposite corner fixed depending on dir
733822 let { minX, minY, maxX, maxY } = origBBox ;
@@ -846,14 +935,15 @@ export const Canvas = () => {
846935 setIsPointerDown ( false ) ;
847936 if ( ! isDrawing ) return ;
848937 setIsDrawing ( false ) ;
938+ const prevMode = manipulationMode . current ?. mode ;
849939 newShapeId . current = null ;
850940 manipulationMode . current = null ;
851- if ( manipulationMode . current ?. mode === "erase" ) {
852- const ctx = canvasRef . current . getContext ( "2d" ) ;
853- ctx . globalCompositeOperation = "source-over" ;
854- }
941+ if ( prevMode === "erase" ) {
942+ const ctx = canvasRef . current ? .getContext ( "2d" ) ;
943+ if ( ctx ) ctx . globalCompositeOperation = "source-over" ;
944+ }
855945
856- } ;
946+ } ;
857947
858948 // delete
859949 const handleDeleteSelectedShape = useCallback ( ( ) => {
@@ -1026,6 +1116,7 @@ export const Canvas = () => {
10261116 onToolChange = { handleToolChange }
10271117 onClear = { handleClear }
10281118 onExport = { handleExport }
1119+ onImageUpload = { handleImageUpload }
10291120 />
10301121
10311122 { joined ? (
0 commit comments