Spaces:
Running
Running
Refactor canvas tools: Fixed drawing logic, improved text input, and added local image drag-and-drop support
afbb4df | import React, { useRef, useEffect, useState, useCallback } from 'react'; | |
| import { NodeResizer, Handle, Position } from '@xyflow/react'; | |
| import { Pencil, Eraser, Trash2 } from 'lucide-react'; | |
| import { useTheme } from '../../../../context/ThemeContext'; | |
| const DrawNode = ({ id, data, selected }) => { | |
| const { isDarkMode } = useTheme(); | |
| const canvasRef = useRef(null); | |
| const [isDrawing, setIsDrawing] = useState(false); | |
| const [tool, setTool] = useState('pencil'); | |
| // Use a ref for drawing properties to avoid re-renders during active draw | |
| const drawState = useRef({ | |
| ctx: null, | |
| lastX: 0, | |
| lastY: 0 | |
| }); | |
| const initCanvas = useCallback(() => { | |
| const canvas = canvasRef.current; | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| drawState.current.ctx = ctx; | |
| // Use absolute size from data or measure parent | |
| const rect = canvas.getBoundingClientRect(); | |
| if (canvas.width !== rect.width || canvas.height !== rect.height) { | |
| // Save current content before resizing | |
| const tempImage = canvas.toDataURL(); | |
| canvas.width = rect.width; | |
| canvas.height = rect.height; | |
| const img = new Image(); | |
| img.onload = () => ctx.drawImage(img, 0, 0); | |
| img.src = tempImage || data.image; | |
| } | |
| if (data.image && !isDrawing) { | |
| const img = new Image(); | |
| img.onload = () => ctx.drawImage(img, 0, 0); | |
| img.src = data.image; | |
| } | |
| }, [data.image, isDrawing]); | |
| useEffect(() => { | |
| initCanvas(); | |
| }, [initCanvas, data.width, data.height]); | |
| const getCoordinates = (e) => { | |
| const canvas = canvasRef.current; | |
| const rect = canvas.getBoundingClientRect(); | |
| // Support both mouse and touch | |
| const clientX = e.clientX || (e.touches && e.touches[0].clientX); | |
| const clientY = e.clientY || (e.touches && e.touches[0].clientY); | |
| return { | |
| x: clientX - rect.left, | |
| y: clientY - rect.top | |
| }; | |
| }; | |
| const startDrawing = (e) => { | |
| if (!selected) return; | |
| setIsDrawing(true); | |
| const { x, y } = getCoordinates(e); | |
| drawState.current.lastX = x; | |
| drawState.current.lastY = y; | |
| }; | |
| const draw = (e) => { | |
| if (!isDrawing || !selected) return; | |
| // Prevent canvas movement while drawing | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| const { x, y } = getCoordinates(e); | |
| const ctx = drawState.current.ctx; | |
| ctx.beginPath(); | |
| ctx.moveTo(drawState.current.lastX, drawState.current.lastY); | |
| ctx.lineTo(x, y); | |
| ctx.strokeStyle = tool === 'eraser' ? (isDarkMode ? '#1e1e1e' : '#ffffff') : (data.strokeColor || (isDarkMode ? '#ffffff' : '#000000')); | |
| ctx.lineWidth = tool === 'eraser' ? 20 : (data.lineWidth || 3); | |
| ctx.lineCap = 'round'; | |
| ctx.lineJoin = 'round'; | |
| ctx.globalCompositeOperation = tool === 'eraser' ? 'destination-out' : 'source-over'; | |
| ctx.stroke(); | |
| drawState.current.lastX = x; | |
| drawState.current.lastY = y; | |
| }; | |
| const stopDrawing = () => { | |
| if (!isDrawing) return; | |
| setIsDrawing(false); | |
| const canvas = canvasRef.current; | |
| if (data.updateNodeData) { | |
| data.updateNodeData(id, { image: canvas.toDataURL() }); | |
| } | |
| }; | |
| const clearCanvas = (e) => { | |
| e.stopPropagation(); | |
| const ctx = drawState.current.ctx; | |
| ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); | |
| if (data.updateNodeData) { | |
| data.updateNodeData(id, { image: null }); | |
| } | |
| }; | |
| return ( | |
| <div | |
| className={`relative group transition-all rounded-xl ${ | |
| selected ? 'ring-2 ring-primary shadow-2xl bg-surface-container-low/20' : 'hover:bg-primary/5' | |
| }`} | |
| style={{ | |
| width: data.width || 400, | |
| height: data.height || 300, | |
| }} | |
| > | |
| <NodeResizer | |
| isVisible={selected} | |
| minWidth={100} | |
| minHeight={100} | |
| onResizeEnd={() => initCanvas()} | |
| /> | |
| {selected && ( | |
| <div | |
| className="absolute -top-12 left-1/2 -translate-x-1/2 flex items-center gap-1 bg-surface-container-lowest border-2 border-primary rounded-xl px-2 py-1 shadow-lg z-[100]" | |
| onMouseDown={e => e.stopPropagation()} // Prevent node drag when clicking toolbar | |
| > | |
| <button | |
| onClick={() => setTool('pencil')} | |
| className={`p-1.5 rounded-lg ${tool === 'pencil' ? 'bg-primary text-white' : 'text-primary hover:bg-surface-variant'}`} | |
| > | |
| <Pencil size={14} /> | |
| </button> | |
| <button | |
| onClick={() => setTool('eraser')} | |
| className={`p-1.5 rounded-lg ${tool === 'eraser' ? 'bg-primary text-white' : 'text-primary hover:bg-surface-variant'}`} | |
| > | |
| <Eraser size={14} /> | |
| </button> | |
| <div className="w-[1px] h-4 bg-primary/20 mx-1"></div> | |
| <button | |
| onClick={clearCanvas} | |
| className="p-1.5 rounded-lg text-red-500 hover:bg-red-500/10" | |
| > | |
| <Trash2 size={14} /> | |
| </button> | |
| </div> | |
| )} | |
| <canvas | |
| ref={canvasRef} | |
| onMouseDown={startDrawing} | |
| onMouseMove={draw} | |
| onMouseUp={stopDrawing} | |
| onMouseLeave={stopDrawing} | |
| onTouchStart={startDrawing} | |
| onTouchMove={draw} | |
| onTouchEnd={stopDrawing} | |
| className={`block w-full h-full touch-none ${selected ? 'cursor-crosshair' : 'cursor-default pointer-events-none'}`} | |
| /> | |
| <Handle type="target" position={Position.Top} className="!opacity-0" /> | |
| <Handle type="source" position={Position.Bottom} className="!opacity-0" /> | |
| </div> | |
| ); | |
| }; | |
| export default DrawNode; | |