| '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; |
| |
| |
| 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'); |
| }; |
|
|
| |
| const downloadRegionImage = (dataUrl, regionId) => { |
| const link = document.createElement('a'); |
| link.href = dataUrl; |
| link.download = `region-${regionId}.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; |
| }; |
|
|
| |
| const floodFill = (imageData, startX, startY, visited) => { |
| const width = imageData.width; |
| const height = imageData.height; |
| const queue = new Int32Array(width * height * 2); |
| let queueStart = 0; |
| let queueEnd = 2; |
| queue[0] = startX; |
| queue[1] = startY; |
|
|
| const region = { |
| points: [], |
| minX: startX, |
| maxX: startX, |
| minY: startY, |
| maxY: startY |
| }; |
|
|
| |
| const isWithinBounds = (x, y) => x >= 0 && x < width && y >= 0 && y < height; |
|
|
| |
| 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 }); |
| |
| |
| 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); |
|
|
| |
| 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; |
| }; |
|
|
| |
| const calculateRegionDistance = (region1, region2) => { |
| |
| 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; |
|
|
| |
| 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); |
| }; |
|
|
| |
| 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) => { |
| |
| 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 }); |
|
|
| |
| const padding = 100; |
| bounds.minX -= padding; |
| bounds.minY -= padding; |
| bounds.maxX += padding; |
| bounds.maxY += padding; |
|
|
| |
| 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; |
| |
| |
| const ctx = tempCanvas.getContext("2d", { alpha: true }); |
| const roughCanvas = rough.canvas(tempCanvas); |
|
|
| |
| ctx.save(); |
| ctx.translate(-bounds.minX * scale, -bounds.minY * scale); |
| ctx.scale(scale, scale); |
| elements.forEach(element => drawElement(roughCanvas, ctx, element)); |
| ctx.restore(); |
|
|
| |
| const imageData = ctx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); |
| const visited = new Set(); |
| const initialRegions = []; |
|
|
| |
| const stride = 4; |
| 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) { |
| |
| 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); |
| } |
| } |
| } |
|
|
| |
| 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 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; |
| |
| |
| context.strokeStyle = "#FF5733"; |
| context.lineWidth = 1 / scale; |
| context.setLineDash([5 / scale, 5 / scale]); |
|
|
| |
| context.strokeRect( |
| bounds.x1, |
| bounds.y1, |
| bounds.x2 - bounds.x1, |
| bounds.y2 - bounds.y1 |
| ); |
|
|
| |
| context.setLineDash([]); |
| 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); |
| |
| |
| 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); |
| |
| |
| context.clearRect(0, 0, canvas.width, canvas.height); |
| |
| |
| 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); |
| |
| |
| elements.forEach(element => { |
| if (action === "writing" && selectedElement?.id === element.id) return; |
| drawElement(roughCanvas, context, element); |
| }); |
| |
| |
| 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; |