import React, { useRef, useEffect } from 'react'; import { Stage, Layer, Image as KonvaImage, Rect, Arrow, Text, Transformer } from 'react-konva'; import useImage from 'use-image'; import Konva from 'konva'; import { Shape, ToolType } from '../types'; // Helper component to load the image const URLImage = ({ src, onLoaded }: { src: string; onLoaded: (w: number, h: number) => void }) => { const [image] = useImage(src); useEffect(() => { if (image) { onLoaded(image.width, image.height); } }, [image, onLoaded]); return ; }; interface EditorCanvasProps { imageSrc: string | null; shapes: Shape[]; setShapes: React.Dispatch>; selectedId: string | null; setSelectedId: (id: string | null) => void; tool: ToolType; setTool: (tool: ToolType) => void; stageRef: React.RefObject; onImageDimensionsChange: (width: number, height: number) => void; canvasWidth?: number; canvasHeight?: number; } const EditorCanvas: React.FC = ({ imageSrc, shapes, setShapes, selectedId, setSelectedId, tool, setTool, stageRef, onImageDimensionsChange, canvasWidth, canvasHeight, }) => { const isDrawing = useRef(false); const transformerRef = useRef(null); // Attach transformer to selected shape useEffect(() => { if (selectedId && transformerRef.current && stageRef.current) { const node = stageRef.current.findOne('#' + selectedId); if (node) { transformerRef.current.nodes([node]); transformerRef.current.getLayer()?.batchDraw(); } } }, [selectedId, shapes]); const handleTextDblClick = (e: Konva.KonvaEventObject) => { const textNode = e.target as Konva.Text; const stage = textNode.getStage(); if (!stage) return; // Hide node while editing textNode.hide(); // Determine position const textPosition = textNode.absolutePosition(); const stageBox = stage.container().getBoundingClientRect(); const areaPosition = { x: stageBox.left + textPosition.x, y: stageBox.top + textPosition.y, }; // Create textarea const textarea = document.createElement('textarea'); document.body.appendChild(textarea); textarea.value = textNode.text(); textarea.style.position = 'absolute'; textarea.style.top = `${areaPosition.y + window.scrollY}px`; textarea.style.left = `${areaPosition.x + window.scrollX}px`; textarea.style.width = `${textNode.width() - textNode.padding() * 2}px`; textarea.style.height = `${textNode.height() - textNode.padding() * 2 + 5}px`; textarea.style.fontSize = `${textNode.fontSize()}px`; textarea.style.border = 'none'; textarea.style.padding = '0px'; textarea.style.margin = '0px'; textarea.style.overflow = 'hidden'; textarea.style.background = 'none'; textarea.style.outline = 'none'; textarea.style.resize = 'none'; textarea.style.lineHeight = textNode.lineHeight().toString(); textarea.style.fontFamily = textNode.fontFamily(); textarea.style.fontWeight = textNode.fontStyle(); textarea.style.transformOrigin = 'left top'; textarea.style.textAlign = textNode.align(); textarea.style.color = textNode.fill(); textarea.style.zIndex = '1000'; const rotation = textNode.rotation(); let transform = ''; if (rotation) { transform += `rotateZ(${rotation}deg)`; } textarea.style.transform = transform; // Auto-height textarea.style.height = 'auto'; textarea.style.height = textarea.scrollHeight + 3 + 'px'; textarea.focus(); const removeTextarea = () => { if (textarea.parentNode) { textarea.parentNode.removeChild(textarea); } textNode.show(); }; const handleBlur = () => { const newText = textarea.value; const id = textNode.id(); setShapes(prev => prev.map(s => s.id === id ? { ...s, text: newText } : s)); removeTextarea(); }; const handleKeydown = (e: KeyboardEvent) => { // Enter without shift updates text if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); textarea.blur(); } // Escape cancels if (e.key === 'Escape') { removeTextarea(); } }; textarea.addEventListener('blur', handleBlur); textarea.addEventListener('keydown', handleKeydown); }; const handleMouseDown = (e: Konva.KonvaEventObject) => { if (tool === 'select') { const clickedOnEmpty = e.target === e.target.getStage(); if (clickedOnEmpty) { setSelectedId(null); transformerRef.current?.nodes([]); } return; } isDrawing.current = true; const stage = e.target.getStage(); const pos = stage?.getRelativePointerPosition(); if (!pos) return; // Create new shape based on tool const baseShape = { id: Math.random().toString(36).substr(2, 9), x: pos.x, y: pos.y, stroke: '#be185d', // Default to Pink (cffyin vibes) strokeWidth: 4, }; let newShape: Shape; if (tool === 'rect') { newShape = { ...baseShape, type: 'rect', width: 0, height: 0, fill: 'transparent', cornerRadius: 15, }; } else if (tool === 'arrow') { newShape = { ...baseShape, type: 'arrow', points: [0, 0, 0, 0], // Relative points }; } else if (tool === 'text') { newShape = { ...baseShape, type: 'text', text: 'Double Click to Edit', fontSize: 32, fill: '#be185d', }; // Text doesn't drag-create, it just places isDrawing.current = false; setShapes([...shapes, newShape]); setSelectedId(newShape.id); setTool('select'); return; } else { return; } setShapes([...shapes, newShape]); setSelectedId(newShape.id); }; const handleMouseMove = (e: Konva.KonvaEventObject) => { if (!isDrawing.current || tool === 'select' || tool === 'text') return; const stage = e.target.getStage(); const pos = stage?.getRelativePointerPosition(); if (!pos) return; // Update the last shape const lastShape = shapes[shapes.length - 1]; // Safety check to ensure we are updating the shape we just created if(lastShape.id !== selectedId) return; const newShapes = shapes.slice(0, shapes.length - 1); if (lastShape.type === 'rect') { newShapes.push({ ...lastShape, width: pos.x - lastShape.x, height: pos.y - lastShape.y, }); } else if (lastShape.type === 'arrow') { const relX = pos.x - lastShape.x; const relY = pos.y - lastShape.y; newShapes.push({ ...lastShape, points: [0, 0, relX, relY], }); } setShapes(newShapes); }; const handleMouseUp = () => { if(isDrawing.current) { isDrawing.current = false; // Switch back to select tool after drawing usually improves UX for annotation tools setTool('select'); } }; const handleDragEnd = (e: Konva.KonvaEventObject, id: string) => { // Update shape position in state const shapeIndex = shapes.findIndex(s => s.id === id); if (shapeIndex === -1) return; const updatedShapes = [...shapes]; updatedShapes[shapeIndex] = { ...updatedShapes[shapeIndex], x: e.target.x(), y: e.target.y(), }; setShapes(updatedShapes); }; const handleTransformEnd = (e: Konva.KonvaEventObject) => { // transform end logic to save scale/rotation back to state const node = e.target; const id = node.id(); const shapeIndex = shapes.findIndex(s => s.id === id); if (shapeIndex === -1) return; const scaleX = node.scaleX(); const scaleY = node.scaleY(); // Reset scale to 1 and adjust width/height/points to prevent compounding scales node.scaleX(1); node.scaleY(1); const updatedShapes = [...shapes]; const shape = updatedShapes[shapeIndex]; if (shape.type === 'rect') { updatedShapes[shapeIndex] = { ...shape, x: node.x(), y: node.y(), rotation: node.rotation(), width: Math.max(5, node.width() * scaleX), // Prevent negative width height: Math.max(5, node.height() * scaleY), }; } else if (shape.type === 'text') { updatedShapes[shapeIndex] = { ...shape, x: node.x(), y: node.y(), rotation: node.rotation(), fontSize: shape.fontSize * scaleX, // Approximation for text scaling }; } else if (shape.type === 'arrow') { updatedShapes[shapeIndex] = { ...shape, x: node.x(), y: node.y(), rotation: node.rotation(), }; // Re-apply the scale to the node so visual doesn't jump node.scaleX(scaleX); node.scaleY(scaleY); } setShapes(updatedShapes); }; return ( {imageSrc && ( )} {shapes.map((shape) => { const commonProps = { key: shape.id, id: shape.id, x: shape.x, y: shape.y, rotation: shape.rotation || 0, draggable: tool === 'select', onClick: () => { if(tool === 'select') setSelectedId(shape.id); }, onTap: () => { if(tool === 'select') setSelectedId(shape.id); }, onDragEnd: (e: any) => handleDragEnd(e, shape.id), onTransformEnd: handleTransformEnd, }; if (shape.type === 'rect') { return ( ); } else if (shape.type === 'arrow') { return ( ); } else if (shape.type === 'text') { return ( ); } return null; })} {selectedId && ( { // limit resize if (newBox.width < 5 || newBox.height < 5) { return oldBox; } return newBox; }} rotateEnabled={true} borderStroke="#d97706" // amber-600 anchorStroke="#451a03" // amber-950 anchorFill="#fcd34d" // amber-300 anchorSize={10} anchorCornerRadius={2} /> )} ); }; export default EditorCanvas;