import React, { useRef, useState, useEffect, useCallback } from 'react'; import { DrawingCanvasProps, Point, Shape, ShapeType } from 'l33t-lib'; import { Command, createShapeCommand } from './commands'; import { EventHandlerDependencies, createCanvasEventHandlers } from './canvasEventHandlers'; const generateId = () => { return Math.random().toString(36).substr(2, 9); }; const StreamCanvas = ({ backgroundImage, initialShapes , onSave }:DrawingCanvasProps) => { const [shapes, setShapes] = useState>(initialShapes); // const [shapes, setShapes] = useState<{ [id: string]: Shape }>(() => { // const shapesObject: { [id: string]: Shape } = {}; // if(initialShapes) // initialShapes.forEach(shape => { // shapesObject[shape.id] = shape; // }); // return shapesObject; // }); const [currentShape, setCurrentShape] = useState(null); const [selectedShape, setSelectedShape] = useState(null); const [selectedPoint, setSelectedPoint] = useState<{ shapeId: string, pointIndex: number } | null>(null); const [drawMode, setDrawMode] = useState('polygon'); const [isDragging, setIsDragging] = useState(false); const [isDraggingShape, setIsDraggingShape] = useState(false); const [draggedShapeId, setDraggedShapeId] = useState(null); const [hoveredPoint, setHoveredPoint] = useState<{ shapeId: string, pointIndex: number } | null>(null); const backgroundCanvasRef = useRef(null); const drawingCanvasRef = useRef(null); const dragStartPosition = useRef(null); const draggedShape = useRef(null); const draggedPointInfo = useRef<{ shapeId: string, pointIndex: number } | null>(null); const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(''); const editTimeoutRef = useRef(null); const [hoveredShapeId, setHoveredShapeId] = useState(null); const [isEditing, setIsEditing] = useState(false); const handleRowClick = useCallback((shape: Shape) => { if (!isEditing) { setSelectedShape(shape); } }, [isEditing]); const commandHistory = useRef([]); const redoStack = useRef([]); const undo = useCallback(() => { const command = commandHistory.current.pop(); if (command) { command.undo(); redoStack.current.push(command); } }, []); const redo = useCallback(() => { const command = redoStack.current.pop(); if (command) { command.execute(); commandHistory.current.push(command); } }, []); useEffect(() => { drawBackground(); drawShapes(); resizeCanvases() }, [shapes, currentShape, selectedShape, selectedPoint, hoveredPoint]); const resizeCanvases = () => { const container = backgroundCanvasRef.current?.parentElement; if (!container) return; const { width, height } = container.getBoundingClientRect(); [backgroundCanvasRef, drawingCanvasRef].forEach(canvasRef => { if (canvasRef.current) { canvasRef.current.width = width; canvasRef.current.height = height; canvasRef.current.style.width = `${width}px`; canvasRef.current.style.height = `${height}px`; } }); drawBackground(); drawShapes(); }; useEffect(() => { resizeCanvases(); window.addEventListener('resize', resizeCanvases); return () => window.removeEventListener('resize', resizeCanvases); }, []); const handleKeyDown = useCallback((event: KeyboardEvent) => { const { key, metaKey, ctrlKey, shiftKey } = event; if (key === 'Backspace' && selectedPoint) { // deleteSelectedVertex(); } else if (key === 'Backspace' && selectedShape) { // deleteSelectedShape(); } else if (metaKey || ctrlKey) { if (key === 'z') { event.preventDefault(); shiftKey ? redo() : undo(); } else if (key === 'y') { event.preventDefault(); redo(); } } }, [ selectedPoint, selectedShape, undo, redo ]); useEffect(() => { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [handleKeyDown]); /* * All functions are defined here */ const saveShapes = useCallback(() => { // Create a copy of the current shapes record const shapesToSave = { ...shapes }; // Add current shape if it exists and is closed if (currentShape && currentShape.isClosed) { shapesToSave[currentShape.id] = currentShape; } // Filter out unclosed shapes Object.keys(shapesToSave).forEach(key => { if (!shapesToSave[key].isClosed) { delete shapesToSave[key]; } }); // Save the record of shapes onSave(shapesToSave); }, [shapes, currentShape, onSave]); const drawBackground = useCallback(() => { const canvas = backgroundCanvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); const img = new Image(); img.onload = () => { ctx.drawImage(img, 0, 0, canvas.width, canvas.height); }; img.src = backgroundImage; }, [backgroundImage]); const isPointNearOtherPoint = useCallback((point1: Point, point2: Point, threshold: number = 10): boolean => { const dx = point1.x - point2.x; const dy = point1.y - point2.y; return Math.sqrt(dx * dx + dy * dy) < threshold; }, []); const isPointOnLine = useCallback((point: Point, lineStart: Point, lineEnd: Point): boolean => { const distanceThreshold = 5; const d1 = Math.sqrt(Math.pow(point.x - lineStart.x, 2) + Math.pow(point.y - lineStart.y, 2)); const d2 = Math.sqrt(Math.pow(point.x - lineEnd.x, 2) + Math.pow(point.y - lineEnd.y, 2)); const lineLength = Math.sqrt(Math.pow(lineEnd.x - lineStart.x, 2) + Math.pow(lineEnd.y - lineStart.y, 2)); return Math.abs(d1 + d2 - lineLength) < distanceThreshold; }, []); const getCoordinates = useCallback((event: React.MouseEvent): Point => { const canvas = drawingCanvasRef.current; if (!canvas) return { x: 0, y: 0 }; const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const x = (event.clientX - rect.left) * scaleX; const y = (event.clientY - rect.top) * scaleY; return { x, y }; }, []); const drawShapes = useCallback(() => { const canvas = drawingCanvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); [...Object.values(shapes), currentShape].filter(Boolean).forEach((shape) => { if (!shape) return; // Draw shape outline ctx.beginPath(); // Shape is red only when selected AND no vertex is selected ctx.strokeStyle = (shape === selectedShape && !selectedPoint) ? 'red' : 'black'; ctx.lineWidth = shape.id === hoveredShapeId || shape === selectedShape ? 3 : 2; shape.points?.forEach((point, index) => { if (index === 0) { ctx.moveTo(point.x, point.y); } else { ctx.lineTo(point.x, point.y); } }); // Only close the path visually if the shape is marked as closed if (shape.type === 'polygon' && shape.isClosed) { ctx.lineTo(shape.points[0].x, shape.points[0].y); // Connect back to first point ctx.closePath(); ctx.fillStyle = 'rgba(0, 0, 255, 0.1)'; ctx.fill(); } ctx.stroke(); // Draw vertices shape.points?.forEach((point, index) => { ctx.beginPath(); const isSelected = selectedPoint && selectedPoint.shapeId === shape.id && selectedPoint.pointIndex === index; const isHovered = hoveredPoint && hoveredPoint.shapeId === shape.id && hoveredPoint.pointIndex === index; let pointRadius = 8; if (isHovered) pointRadius = 9; if (isSelected) pointRadius = 10; ctx.arc(point.x, point.y, pointRadius, 0, 2 * Math.PI); if (isSelected) { ctx.fillStyle = 'red'; } else if (isHovered) { ctx.fillStyle = 'blue'; } else { ctx.fillStyle = 'rgba(0, 0, 255, 0.5)'; } ctx.fill(); ctx.strokeStyle = 'white'; ctx.lineWidth = isSelected ? 3 : 2; ctx.stroke(); }); }); }, [shapes, currentShape, selectedShape, selectedPoint, hoveredPoint, hoveredShapeId]); // /*** end functions */ /** * commands whith undo/redo * */ const commandContext = useCallback(() => ({ shapes, setShapes, setCurrentShape, setSelectedShape, setSelectedPoint, selectedShape, selectedPoint, }), [shapes, selectedShape, selectedPoint]); const executeCommand = useCallback((command: Command) => { command.execute(); commandHistory.current.push(command); redoStack.current = []; }, []); /*** commands whith undo/redo */ const addPoint = useCallback((point: Point) => { const command = createShapeCommand('AddPoint', commandContext(), { point, drawMode, currentShape: currentShape || { id: generateId(), type: drawMode, points: [], isClosed: false } }); executeCommand(command); }, [currentShape, drawMode, commandContext, executeCommand]); const closePolygon = useCallback(() => { if (currentShape && currentShape.points.length > 2) { const command = createShapeCommand('ClosePolygon', commandContext(), { currentShape: { ...currentShape, isClosed: true, points: [...currentShape.points] } }); executeCommand(command); } }, [currentShape, commandContext, executeCommand]); const deleteSelectedShape = useCallback(() => { if (selectedShape) { const command = createShapeCommand('DeleteShape', commandContext(), { currentShape: selectedShape }); executeCommand(command); setCurrentShape(null); drawShapes(); } }, [selectedShape, commandContext, executeCommand, drawShapes]); const deleteSelectedVertex = useCallback(() => { if (selectedPoint) { const command = createShapeCommand('DeleteVertex', commandContext(), { currentShape: shapes[selectedPoint.shapeId], selectedPoint: selectedPoint }); executeCommand(command); } }, [selectedPoint, shapes, commandContext, executeCommand]); const insertPointIntoShape = useCallback((shapeId: string, index: number, point: Point) => { const command = createShapeCommand('InsertPointInShape', commandContext(), { currentShape: shapes[shapeId], shapeId: shapeId, index: index, point: point }); executeCommand(command); }, [shapes, commandContext, executeCommand]); const isPointInsidePolygon = useCallback((point: Point, polygon: Point[]): boolean => { let inside = false; for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { const xi = polygon[i].x, yi = polygon[i].y; const xj = polygon[j].x, yj = polygon[j].y; const intersect = ((yi > point.y) !== (yj > point.y)) && (point.x < (xj - xi) * (point.y - yi) / (yj - yi) + xi); if (intersect) inside = !inside; } return inside; }, []); const _getShape = useCallback((point: Point): Shape | null => { for (const shape of Object.values(shapes)) { if (shape.type === 'polygon' && shape.isClosed === true) { const isInside = isPointInsidePolygon(point, shape.points); if (isInside) return shape; } } return null; }, [shapes, isPointInsidePolygon]); const handleCanvasClick = useCallback((event: React.MouseEvent) => { const point = getCoordinates(event); const clickedPolygon = _getShape(point); if (clickedPolygon) { setSelectedShape(clickedPolygon); setSelectedPoint(null); return; } if (currentShape && currentShape.type === 'polygon' && currentShape.points.length > 2) { const startPoint = currentShape.points[0]; if (isPointNearOtherPoint(point, startPoint)) { // Close the polygon without adding the first point again closePolygon(); return; } } const clickedShape = Object.values(shapes).find(shape => shape.points?.some((shapePoint, index) => { if (isPointNearOtherPoint(point, shapePoint)) { setSelectedPoint({ shapeId: shape.id, pointIndex: index }); setSelectedShape(shape); return true; } return false; }) ); if (clickedShape) { return; } const clickedLine = Object.values(shapes).find(shape => shape.points?.some((shapePoint, index) => { if (index < shape.points.length - 1) { const nextPoint = shape.points[index + 1]; if (isPointOnLine(point, shapePoint, nextPoint)) { insertPointIntoShape(shape.id, index + 1, point); return true; } } return false; }) ); if (clickedLine) { return; } addPoint(point); }, [shapes, getCoordinates, currentShape, isPointNearOtherPoint, isPointOnLine, insertPointIntoShape, addPoint, closePolygon, _getShape]); const updateShapeId = useCallback((oldId: string, newId: string) => { if (oldId === newId || shapes[newId]) return; setShapes(prevShapes => { const updatedShapes = { ...prevShapes }; const shape = updatedShapes[oldId]; delete updatedShapes[oldId]; updatedShapes[newId] = { ...shape, id: newId }; return updatedShapes; }); if (selectedShape?.id === oldId) { setSelectedShape(prevSelected => prevSelected ? { ...prevSelected, id: newId } : null); } setEditingId(null); }, [shapes, selectedShape]); const handleIdChange = useCallback((shapeId: string, newId: string) => { if (editTimeoutRef.current) { clearTimeout(editTimeoutRef.current); } editTimeoutRef.current = setTimeout(() => { updateShapeId(shapeId, newId); }, 500); }, [updateShapeId]); const startEditing = useCallback((shapeId: string) => { setIsEditing(true); setEditingId(shapeId); setEditValue(shapeId); }, []); const stopEditing = useCallback(() => { setIsEditing(false); if (editingId && editValue) { handleIdChange(editingId, editValue); } setEditingId(null); setEditValue(''); }, [editingId, editValue, handleIdChange]); /*** end undo / redo commands */ const eventHandlerDeps: EventHandlerDependencies = { shapes, currentShape, selectedShape, selectedPoint, isDragging, isDraggingShape, draggedShapeId, setShapes, setCurrentShape, setSelectedShape, setSelectedPoint, setIsDragging, setIsDraggingShape, setDraggedShapeId, setHoveredPoint, drawingCanvasRef, dragStartPosition, draggedShape, draggedPointInfo, commandContext, executeCommand, getCoordinates, isPointNearOtherPoint, isPointOnLine, _getShape, }; const { handleCanvasMouseMove, handleCanvasMouseDown, handleCanvasMouseUp } = createCanvasEventHandlers(eventHandlerDeps); return (
{Object.values(shapes).map((shape) => ( handleRowClick(shape)} // onMouseEnter={() => handleRowMouseEnter(shape)} // onMouseLeave={handleRowMouseLeave} > ))}
ID Type
{editingId === shape.id ? ( setEditValue(e.target.value)} onBlur={stopEditing} onKeyDown={(e) => { if (e.key === 'Enter') { stopEditing(); } }} className="bg-white text-black px-3 py-1 rounded-md border border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all duration-200 ease-in-out shadow-sm hover:shadow-md w-full" autoFocus onClick={(e) => e.stopPropagation()} /> ) : ( { e.stopPropagation(); startEditing(shape.id); }} className="cursor-pointer hover:text-indigo-300 transition-colors duration-200 ease-in-out" > {shape.id} )} {shape.type}
); }; export default StreamCanvas;