Spaces:
No application file
No application file
| 'use client' | |
| import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; | |
| import rough from "roughjs/bundled/rough.esm"; | |
| import DrawingBoard from './DrawingBoard'; | |
| import { | |
| createElement, | |
| drawElement, | |
| } from './element-utils'; | |
| import { createMouseHandlers } from './handleMouse'; | |
| const captureRegionToPNG = (canvas, region, elements, scale, panOffset, scaleOffset) => { | |
| const tempCanvas = document.createElement('canvas'); | |
| const ctx = tempCanvas.getContext('2d'); | |
| const width = (region.bounds.x2 - region.bounds.x1); | |
| const height = (region.bounds.y2 - region.bounds.y1); | |
| tempCanvas.width = width * scale; | |
| tempCanvas.height = height * scale; | |
| // Fill white background | |
| ctx.fillStyle = 'white'; | |
| ctx.fillRect(0, 0, tempCanvas.width, tempCanvas.height); | |
| ctx.save(); | |
| ctx.scale(scale, scale); | |
| ctx.translate(-region.bounds.x1, -region.bounds.y1); | |
| const mainCanvas = canvas.getContext('2d'); | |
| const roughCanvas = rough.canvas(tempCanvas); | |
| ctx.translate(panOffset.x, panOffset.y); | |
| elements.forEach(element => { | |
| if (isElementInRegion(element, region.bounds)) { | |
| drawElement(roughCanvas, ctx, element); | |
| } | |
| }); | |
| ctx.restore(); | |
| return tempCanvas.toDataURL('image/png'); | |
| }; | |
| // Update the downloadRegionImage function: | |
| const downloadRegionImage = (dataUrl, regionId) => { | |
| const link = document.createElement('a'); | |
| link.href = dataUrl; | |
| link.download = `region-${regionId}.png`; // Changed extension to .png | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| }; | |
| const isElementInRegion = (element, bounds) => { | |
| if (element.type === 'pencil') { | |
| return element.points.some(point => | |
| point.x >= bounds.x1 && point.x <= bounds.x2 && | |
| point.y >= bounds.y1 && point.y <= bounds.y2 | |
| ); | |
| } else { | |
| const elementBounds = { | |
| minX: Math.min(element.x1, element.x2), | |
| maxX: Math.max(element.x1, element.x2), | |
| minY: Math.min(element.y1, element.y2), | |
| maxY: Math.max(element.y1, element.y2) | |
| }; | |
| return !(elementBounds.maxX < bounds.x1 || | |
| elementBounds.minX > bounds.x2 || | |
| elementBounds.maxY < bounds.y1 || | |
| elementBounds.minY > bounds.y2); | |
| } | |
| }; | |
| const downloadAllRegions = (canvas, regions, elements, scale, panOffset, scaleOffset) => { | |
| regions.forEach(region => { | |
| const dataUrl = captureRegionToPNG(canvas, region, elements, scale, panOffset, scaleOffset); | |
| downloadRegionImage(dataUrl, region.id); | |
| }); | |
| }; | |
| const isPixelNonTransparent = (pixel) => { | |
| return pixel.a > 0; | |
| }; | |
| const isWithinBounds = (x, y, width, height) => { | |
| return x >= 0 && x < width && y >= 0 && y < height; | |
| }; | |
| const getPixel = (imageData, x, y) => { | |
| const index = (y * imageData.width + x) * 4; | |
| return imageData.data[index + 3] > 0; // Only check alpha channel for performance | |
| }; | |
| // Use Set for faster lookups and Int32Array for coordinates | |
| const floodFill = (imageData, startX, startY, visited) => { | |
| const width = imageData.width; | |
| const height = imageData.height; | |
| const queue = new Int32Array(width * height * 2); // Pre-allocate queue | |
| let queueStart = 0; | |
| let queueEnd = 2; | |
| queue[0] = startX; | |
| queue[1] = startY; | |
| const region = { | |
| points: [], | |
| minX: startX, | |
| maxX: startX, | |
| minY: startY, | |
| maxY: startY | |
| }; | |
| // Optimize bounds checking | |
| const isWithinBounds = (x, y) => x >= 0 && x < width && y >= 0 && y < height; | |
| // Pre-calculate neighbor offsets | |
| const neighborOffsets = [ | |
| [1, 0], [-1, 0], | |
| [0, 1], [0, -1], | |
| [1, 1], [-1, -1], | |
| [1, -1], [-1, 1] | |
| ]; | |
| while (queueStart < queueEnd) { | |
| const x = queue[queueStart]; | |
| const y = queue[queueStart + 1]; | |
| queueStart += 2; | |
| const key = `${x},${y}`; | |
| if (visited.has(key)) continue; | |
| if (!getPixel(imageData, x, y)) continue; | |
| visited.add(key); | |
| region.points.push({ x, y }); | |
| // Use Math.min/max for bounds tracking | |
| region.minX = Math.min(region.minX, x); | |
| region.maxX = Math.max(region.maxX, x); | |
| region.minY = Math.min(region.minY, y); | |
| region.maxY = Math.max(region.maxY, y); | |
| // Check neighbors using pre-calculated offsets | |
| for (const [dx, dy] of neighborOffsets) { | |
| const nx = x + dx; | |
| const ny = y + dy; | |
| if (isWithinBounds(nx, ny) && !visited.has(`${nx},${ny}`)) { | |
| queue[queueEnd] = nx; | |
| queue[queueEnd + 1] = ny; | |
| queueEnd += 2; | |
| } | |
| } | |
| } | |
| return region; | |
| }; | |
| // Optimize distance calculation | |
| const calculateRegionDistance = (region1, region2) => { | |
| // Quick overlap check | |
| const xOverlap = !(region1.maxX < region2.minX || region1.minX > region2.maxX); | |
| const yOverlap = !(region1.maxY < region2.minY || region1.minY > region2.maxY); | |
| if (xOverlap && yOverlap) return 0; | |
| // Calculate distance only when necessary | |
| const dx = !xOverlap ? Math.min( | |
| Math.abs(region1.maxX - region2.minX), | |
| Math.abs(region1.minX - region2.maxX) | |
| ) : 0; | |
| const dy = !yOverlap ? Math.min( | |
| Math.abs(region1.maxY - region2.minY), | |
| Math.abs(region1.minY - region2.maxY) | |
| ) : 0; | |
| return Math.sqrt(dx * dx + dy * dy); | |
| }; | |
| // Optimize region merging using Set | |
| const mergeRegions = (region1, region2) => ({ | |
| points: [...region1.points, ...region2.points], | |
| minX: Math.min(region1.minX, region2.minX), | |
| maxX: Math.max(region1.maxX, region2.maxX), | |
| minY: Math.min(region1.minY, region2.minY), | |
| maxY: Math.max(region1.maxY, region2.maxY) | |
| }); | |
| const detectRegions = (canvas, elements, panOffset, scale, scaleOffset, minRegionSize = 100, groupingDistance = 80) => { | |
| // Calculate bounds only once | |
| const bounds = elements.reduce((acc, element) => { | |
| if (element.type === 'pencil') { | |
| element.points.forEach(point => { | |
| acc.minX = Math.min(acc.minX, point.x); | |
| acc.minY = Math.min(acc.minY, point.y); | |
| acc.maxX = Math.max(acc.maxX, point.x); | |
| acc.maxY = Math.max(acc.maxY, point.y); | |
| }); | |
| } else { | |
| const { x1, y1, x2, y2 } = element; | |
| acc.minX = Math.min(acc.minX, x1, x2); | |
| acc.minY = Math.min(acc.minY, y1, y2); | |
| acc.maxX = Math.max(acc.maxX, x1, x2); | |
| acc.maxY = Math.max(acc.maxY, y1, y2); | |
| } | |
| return acc; | |
| }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); | |
| // Add padding | |
| const padding = 100; | |
| bounds.minX -= padding; | |
| bounds.minY -= padding; | |
| bounds.maxX += padding; | |
| bounds.maxY += padding; | |
| // Create optimized temporary canvas | |
| const tempCanvas = document.createElement("canvas"); | |
| const width = Math.max(bounds.maxX - bounds.minX, canvas.width); | |
| const height = Math.max(bounds.maxY - bounds.minY, canvas.height); | |
| tempCanvas.width = width * scale; | |
| tempCanvas.height = height * scale; | |
| // Use OffscreenCanvas when available for better performance | |
| const ctx = tempCanvas.getContext("2d", { alpha: true }); | |
| const roughCanvas = rough.canvas(tempCanvas); | |
| // Draw elements with transformation | |
| ctx.save(); | |
| ctx.translate(-bounds.minX * scale, -bounds.minY * scale); | |
| ctx.scale(scale, scale); | |
| elements.forEach(element => drawElement(roughCanvas, ctx, element)); | |
| ctx.restore(); | |
| // Process image data | |
| const imageData = ctx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); | |
| const visited = new Set(); | |
| const initialRegions = []; | |
| // Optimize pixel scanning with stride | |
| const stride = 4; // Check every 4th pixel initially | |
| for (let y = 0; y < tempCanvas.height; y += stride) { | |
| for (let x = 0; x < tempCanvas.width; x += stride) { | |
| const key = `${x},${y}`; | |
| if (visited.has(key)) continue; | |
| if (!getPixel(imageData, x, y)) continue; | |
| const region = floodFill(imageData, x, y, visited); | |
| if (region.points.length >= minRegionSize) { | |
| // Transform coordinates back | |
| const transformedRegion = { | |
| ...region, | |
| minX: region.minX / scale + bounds.minX, | |
| maxX: region.maxX / scale + bounds.minX, | |
| minY: region.minY / scale + bounds.minY, | |
| maxY: region.maxY / scale + bounds.minY, | |
| points: region.points.map(point => ({ | |
| x: point.x / scale + bounds.minX, | |
| y: point.y / scale + bounds.minY | |
| })) | |
| }; | |
| initialRegions.push(transformedRegion); | |
| } | |
| } | |
| } | |
| // Optimize region merging | |
| const mergedRegions = []; | |
| const used = new Set(); | |
| for (let i = 0; i < initialRegions.length; i++) { | |
| if (used.has(i)) continue; | |
| let currentRegion = initialRegions[i]; | |
| used.add(i); | |
| let merged; | |
| do { | |
| merged = false; | |
| for (let j = 0; j < initialRegions.length; j++) { | |
| if (used.has(j)) continue; | |
| const distance = calculateRegionDistance(currentRegion, initialRegions[j]); | |
| if (distance <= groupingDistance) { | |
| currentRegion = mergeRegions(currentRegion, initialRegions[j]); | |
| used.add(j); | |
| merged = true; | |
| } | |
| } | |
| } while (merged); | |
| mergedRegions.push(currentRegion); | |
| } | |
| // Return final regions | |
| return mergedRegions.map((region, index) => ({ | |
| id: index + 1, | |
| bounds: { | |
| x1: region.minX, | |
| y1: region.minY, | |
| x2: region.maxX, | |
| y2: region.maxY | |
| }, | |
| elements: [] | |
| })); | |
| }; | |
| const useHistory = initialState => { | |
| const [index, setIndex] = useState(0); | |
| const [history, setHistory] = useState([initialState]); | |
| const setState = (action, overwrite = false) => { | |
| const newState = typeof action === "function" ? action(history[index]) : action; | |
| if (overwrite) { | |
| const historyCopy = [...history]; | |
| historyCopy[index] = newState; | |
| setHistory(historyCopy); | |
| } else { | |
| const updatedState = [...history].slice(0, index + 1); | |
| setHistory([...updatedState, newState]); | |
| setIndex(prevState => prevState + 1); | |
| } | |
| }; | |
| const undo = () => index > 0 && setIndex(prevState => prevState - 1); | |
| const redo = () => index < history.length - 1 && setIndex(prevState => prevState + 1); | |
| return [history[index], setState, undo, redo]; | |
| }; | |
| const usePressedKeys = () => { | |
| const [pressedKeys, setPressedKeys] = useState(new Set()); | |
| useEffect(() => { | |
| const handleKeyDown = event => { | |
| setPressedKeys(prevKeys => new Set(prevKeys).add(event.key)); | |
| }; | |
| const handleKeyUp = event => { | |
| setPressedKeys(prevKeys => { | |
| const updatedKeys = new Set(prevKeys); | |
| updatedKeys.delete(event.key); | |
| return updatedKeys; | |
| }); | |
| }; | |
| window.addEventListener("keydown", handleKeyDown); | |
| window.addEventListener("keyup", handleKeyUp); | |
| return () => { | |
| window.removeEventListener("keydown", handleKeyDown); | |
| window.removeEventListener("keyup", handleKeyUp); | |
| }; | |
| }, []); | |
| return pressedKeys; | |
| }; | |
| const drawBoundingBoxes = (context, regions, scale) => { | |
| context.save(); | |
| regions.forEach(region => { | |
| const { bounds, id } = region; | |
| // Set styling for bounding box | |
| context.strokeStyle = "#FF5733"; // Custom color for better visibility | |
| context.lineWidth = 1 / scale; | |
| context.setLineDash([5 / scale, 5 / scale]); // Dotted lines for bounding box | |
| // Draw bounding box | |
| context.strokeRect( | |
| bounds.x1, | |
| bounds.y1, | |
| bounds.x2 - bounds.x1, | |
| bounds.y2 - bounds.y1 | |
| ); | |
| // Display region ID inside bounding box | |
| context.setLineDash([]); // Reset line dash for text | |
| context.font = `${16 / scale}px sans-serif`; | |
| context.fillStyle = "#FF5733"; | |
| context.fillText( | |
| `#${id}`, | |
| bounds.x1 + 5 / scale, | |
| bounds.y1 + 20 / scale | |
| ); | |
| }); | |
| context.restore(); | |
| }; | |
| const App = () => { | |
| const [elements, setElements, undo, redo] = useHistory([]); | |
| const [action, setAction] = useState("none"); | |
| const [tool, setTool] = useState("rectangle"); | |
| const [selectedElement, setSelectedElement] = useState(null); | |
| const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); | |
| const [startPanMousePosition, setStartPanMousePosition] = useState({ x: 0, y: 0 }); | |
| const [scale, setScale] = useState(1); | |
| const [scaleOffset, setScaleOffset] = useState({ x: 0, y: 0 }); | |
| const [captureArea, setCaptureArea] = useState(null); | |
| const [drawingRegions, setDrawingRegions] = useState([]); | |
| const textAreaRef = useRef(); | |
| const pressedKeys = usePressedKeys(); | |
| const [pencilSize, setPencilSize] = useState(3); | |
| const [isDrawing, setIsDrawing] = useState(false); | |
| const handleDetectRegions = () => { | |
| const canvas = document.getElementById("canvas"); | |
| if (!canvas) return; | |
| const newRegions = detectRegions(canvas, elements, panOffset, scale, scaleOffset); | |
| setDrawingRegions(newRegions); | |
| // Notify user | |
| if (newRegions.length > 0) { | |
| console.log(`Phát hiện ${newRegions.length} vùng vẽ!`); | |
| alert(`Đã phát hiện ${newRegions.length} vùng vẽ!`); | |
| } else { | |
| alert("Không phát hiện được vùng vẽ nào!"); | |
| } | |
| }; | |
| useLayoutEffect(() => { | |
| const canvas = document.getElementById("canvas"); | |
| const context = canvas.getContext("2d"); | |
| const roughCanvas = rough.canvas(canvas); | |
| // Clear canvas for redrawing | |
| context.clearRect(0, 0, canvas.width, canvas.height); | |
| // Calculate scaled dimensions | |
| const scaledWidth = canvas.width * scale; | |
| const scaledHeight = canvas.height * scale; | |
| const scaleOffsetX = (scaledWidth - canvas.width) / 2; | |
| const scaleOffsetY = (scaledHeight - canvas.height) / 2; | |
| setScaleOffset({ x: scaleOffsetX, y: scaleOffsetY }); | |
| context.save(); | |
| context.translate(panOffset.x * scale - scaleOffsetX, panOffset.y * scale - scaleOffsetY); | |
| context.scale(scale, scale); | |
| // Draw elements | |
| elements.forEach(element => { | |
| if (action === "writing" && selectedElement?.id === element.id) return; | |
| drawElement(roughCanvas, context, element); | |
| }); | |
| // Call drawBoundingBoxes to render detected regions | |
| drawBoundingBoxes(context, drawingRegions, scale); | |
| context.restore(); | |
| }, [elements, action, selectedElement, panOffset, scale, drawingRegions]); | |
| useEffect(() => { | |
| const undoRedoFunction = event => { | |
| if ((event.metaKey || event.ctrlKey) && event.key === "z") { | |
| if (event.shiftKey) { | |
| redo(); | |
| } else { | |
| undo(); | |
| } | |
| } | |
| }; | |
| document.addEventListener("keydown", undoRedoFunction); | |
| return () => { | |
| document.removeEventListener("keydown", undoRedoFunction); | |
| }; | |
| }, [undo, redo]); | |
| useEffect(() => { | |
| const panOrZoomFunction = event => { | |
| if (pressedKeys.has("Meta") || pressedKeys.has("Control")) { | |
| onZoom(event.deltaY * -0.01); | |
| } else { | |
| setPanOffset(prevState => ({ | |
| x: prevState.x - event.deltaX, | |
| y: prevState.y - event.deltaY, | |
| })); | |
| } | |
| }; | |
| document.addEventListener("wheel", panOrZoomFunction); | |
| return () => { | |
| document.removeEventListener("wheel", panOrZoomFunction); | |
| }; | |
| }, [pressedKeys]); | |
| const onZoom = delta => { | |
| setScale(prevState => Math.min(Math.max(prevState + delta, 0.1), 2)); | |
| }; | |
| const handleDownloadRegions = () => { | |
| const canvas = document.getElementById("canvas"); | |
| if (!canvas || drawingRegions.length === 0) { | |
| alert("Không có vùng vẽ nào để tải xuống!"); | |
| return; | |
| } | |
| downloadAllRegions(canvas, drawingRegions, elements, scale, panOffset, scaleOffset); | |
| alert(`Đã tải xuống ${drawingRegions.length} vùng vẽ!`); | |
| }; | |
| const { | |
| handleMouseDown, | |
| handleMouseMove, | |
| handleMouseUp, | |
| handleBlur | |
| } = createMouseHandlers({ | |
| action, | |
| setAction, | |
| tool, | |
| setTool, | |
| elements, | |
| setElements, | |
| selectedElement, | |
| setSelectedElement, | |
| panOffset, | |
| setPanOffset, | |
| scale, | |
| scaleOffset, | |
| startPanMousePosition, | |
| setStartPanMousePosition, | |
| pressedKeys, | |
| pencilSize, | |
| setCaptureArea, | |
| isDrawing, | |
| setIsDrawing | |
| }); | |
| return ( | |
| <DrawingBoard | |
| tool={tool} | |
| setTool={setTool} | |
| elements={elements} | |
| pencilSize={pencilSize} | |
| setPencilSize={setPencilSize} | |
| scale={scale} | |
| setScale={setScale} | |
| onZoom={onZoom} | |
| undo={undo} | |
| redo={redo} | |
| action={action} | |
| selectedElement={selectedElement} | |
| panOffset={panOffset} | |
| scaleOffset={scaleOffset} | |
| handleMouseDown={handleMouseDown} | |
| handleMouseMove={handleMouseMove} | |
| handleMouseUp={handleMouseUp} | |
| handleBlur={handleBlur} | |
| handleDetectRegions={handleDetectRegions} | |
| handleDownloadRegions={handleDownloadRegions} | |
| /> | |
| ); | |
| }; | |
| export default App; |