Upload 14 files
Browse files- public/file.svg +1 -0
- public/globe.svg +1 -0
- public/next.svg +1 -0
- public/vercel.svg +1 -0
- public/window.svg +1 -0
- src/app/DrawingBoard.js +600 -0
- src/app/element-utils.js +243 -0
- src/app/favicon.ico +0 -0
- src/app/fonts/GeistMonoVF.woff +0 -0
- src/app/fonts/GeistVF.woff +0 -0
- src/app/globals.css +21 -0
- src/app/handleMouse.js +241 -0
- src/app/layout.js +30 -0
- src/app/page.js +548 -0
public/file.svg
ADDED
|
|
public/globe.svg
ADDED
|
|
public/next.svg
ADDED
|
|
public/vercel.svg
ADDED
|
|
public/window.svg
ADDED
|
|
src/app/DrawingBoard.js
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import {
|
| 3 |
+
Undo2,
|
| 4 |
+
Redo2,
|
| 5 |
+
Pencil,
|
| 6 |
+
Hand,
|
| 7 |
+
Square,
|
| 8 |
+
Eraser,
|
| 9 |
+
Sun,
|
| 10 |
+
Moon,
|
| 11 |
+
ZoomIn,
|
| 12 |
+
ZoomOut,
|
| 13 |
+
Grid,
|
| 14 |
+
GripHorizontal,
|
| 15 |
+
RotateCcw,
|
| 16 |
+
Scan,
|
| 17 |
+
Download,
|
| 18 |
+
Menu,
|
| 19 |
+
X,
|
| 20 |
+
Ruler,
|
| 21 |
+
Palette
|
| 22 |
+
} from 'lucide-react';
|
| 23 |
+
|
| 24 |
+
const PRESET_COLORS = [
|
| 25 |
+
'#000000', '#FFFFFF', '#FF4500', '#FFA500',
|
| 26 |
+
'#FF0000', '#FF1493', '#800080', '#4B0082',
|
| 27 |
+
'#008000', '#C0C0C0', '#FFA500', '#4169E1',
|
| 28 |
+
'#00FF00', '#40E0D0', '#0000FF', '#FFD700'
|
| 29 |
+
];
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
const DrawingBoard = ({
|
| 35 |
+
tool,
|
| 36 |
+
setTool,
|
| 37 |
+
elements,
|
| 38 |
+
pencilSize,
|
| 39 |
+
setPencilSize,
|
| 40 |
+
scale,
|
| 41 |
+
setScale,
|
| 42 |
+
onZoom,
|
| 43 |
+
undo,
|
| 44 |
+
redo,
|
| 45 |
+
action,
|
| 46 |
+
selectedElement,
|
| 47 |
+
panOffset,
|
| 48 |
+
scaleOffset,
|
| 49 |
+
handleMouseDown,
|
| 50 |
+
handleMouseMove,
|
| 51 |
+
handleMouseUp,
|
| 52 |
+
handleBlur,
|
| 53 |
+
captureDrawnArea,
|
| 54 |
+
handleDetectRegions,
|
| 55 |
+
handleDownloadRegions
|
| 56 |
+
}) => {
|
| 57 |
+
const textAreaRef = React.useRef(null);
|
| 58 |
+
const backgroundCanvasRef = React.useRef(null);
|
| 59 |
+
const drawingCanvasRef = React.useRef(null);
|
| 60 |
+
const [backgroundType, setBackgroundType] = React.useState('none');
|
| 61 |
+
const [gridSize, setGridSize] = React.useState(45);
|
| 62 |
+
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
|
| 63 |
+
const [showDownloadButton, setShowDownloadButton] = React.useState(false);
|
| 64 |
+
const [showColorPicker, setShowColorPicker] = React.useState(false);
|
| 65 |
+
const [currentColor, setCurrentColor] = React.useState('#000000');
|
| 66 |
+
const [customColor, setCustomColor] = React.useState('#000000');
|
| 67 |
+
const [darkMode, setDarkMode] = React.useState(() => {
|
| 68 |
+
const savedMode = localStorage.getItem('drawingBoardDarkMode');
|
| 69 |
+
if (savedMode !== null) {
|
| 70 |
+
return savedMode === 'true';
|
| 71 |
+
}
|
| 72 |
+
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
React.useEffect(() => {
|
| 76 |
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
| 77 |
+
const handleChange = (e) => {
|
| 78 |
+
const savedMode = localStorage.getItem('drawingBoardDarkMode');
|
| 79 |
+
if (savedMode === null) {
|
| 80 |
+
setDarkMode(e.matches);
|
| 81 |
+
}
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
mediaQuery.addEventListener('change', handleChange);
|
| 85 |
+
return () => mediaQuery.removeEventListener('change', handleChange);
|
| 86 |
+
}, []);
|
| 87 |
+
|
| 88 |
+
React.useEffect(() => {
|
| 89 |
+
localStorage.setItem('drawingBoardDarkMode', darkMode);
|
| 90 |
+
drawElements();
|
| 91 |
+
}, [darkMode]);
|
| 92 |
+
|
| 93 |
+
const drawBackground = React.useCallback(() => {
|
| 94 |
+
const canvas = backgroundCanvasRef.current;
|
| 95 |
+
if (!canvas) return;
|
| 96 |
+
|
| 97 |
+
const ctx = canvas.getContext('2d');
|
| 98 |
+
const width = canvas.width;
|
| 99 |
+
const height = canvas.height;
|
| 100 |
+
|
| 101 |
+
ctx.fillStyle = darkMode ? '#ffffff' : '#000000';
|
| 102 |
+
ctx.fillRect(0, 0, width, height);
|
| 103 |
+
|
| 104 |
+
const offsetX = (panOffset.x * scale - scaleOffset.x) % (gridSize * scale);
|
| 105 |
+
const offsetY = (panOffset.y * scale - scaleOffset.y) % (gridSize * scale);
|
| 106 |
+
const gridColor = darkMode ? 'rgba(0, 0, 0, 0.6)' : 'rgba(255, 255, 255, 0.6)';
|
| 107 |
+
|
| 108 |
+
if (backgroundType === 'grid') {
|
| 109 |
+
const majorGridSize = gridSize *5;
|
| 110 |
+
const majorGridColor = darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
|
| 111 |
+
const minorGridColor = darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
| 112 |
+
ctx.beginPath();
|
| 113 |
+
ctx.strokeStyle = gridColor;
|
| 114 |
+
ctx.lineWidth = 0.5;
|
| 115 |
+
|
| 116 |
+
for (let x = offsetX; x < width; x += gridSize * scale) {
|
| 117 |
+
ctx.moveTo(x, 0);
|
| 118 |
+
ctx.lineTo(x, height);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
for (let y = offsetY; y < height; y += gridSize * scale) {
|
| 122 |
+
ctx.moveTo(0, y);
|
| 123 |
+
ctx.lineTo(width, y);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
ctx.stroke();
|
| 127 |
+
ctx.beginPath();
|
| 128 |
+
ctx.strokeStyle = majorGridColor;
|
| 129 |
+
ctx.lineWidth = 1.5;
|
| 130 |
+
|
| 131 |
+
// Tính offset cho grid lớn
|
| 132 |
+
const majorOffsetX = offsetX - (offsetX % (majorGridSize * scale));
|
| 133 |
+
const majorOffsetY = offsetY - (offsetY % (majorGridSize * scale));
|
| 134 |
+
for (let x = majorOffsetX; x < width; x += majorGridSize * scale) {
|
| 135 |
+
ctx.moveTo(x, 0);
|
| 136 |
+
ctx.lineTo(x, height);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
// Vẽ các đường ngang lớn
|
| 140 |
+
for (let y = majorOffsetY; y < height; y += majorGridSize * scale) {
|
| 141 |
+
ctx.moveTo(0, y);
|
| 142 |
+
ctx.lineTo(width, y);
|
| 143 |
+
}
|
| 144 |
+
ctx.stroke();
|
| 145 |
+
} else if (backgroundType === 'dots') {
|
| 146 |
+
ctx.fillStyle = gridColor;
|
| 147 |
+
const dotSize = 2;
|
| 148 |
+
|
| 149 |
+
for (let x = offsetX; x < width; x += gridSize * scale) {
|
| 150 |
+
for (let y = offsetY; y < height; y += gridSize * scale) {
|
| 151 |
+
ctx.beginPath();
|
| 152 |
+
ctx.arc(x, y, 1, 0, Math.PI * 2);
|
| 153 |
+
ctx.fill();
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
}, [backgroundType, gridSize, scale, panOffset, scaleOffset, darkMode]);
|
| 158 |
+
const toggleBackground = () => {
|
| 159 |
+
setBackgroundType(prev => {
|
| 160 |
+
if (prev === 'none') return 'grid';
|
| 161 |
+
if (prev === 'grid') return 'dots';
|
| 162 |
+
return 'none';
|
| 163 |
+
});
|
| 164 |
+
};
|
| 165 |
+
const drawElements = React.useCallback(() => {
|
| 166 |
+
const canvas = drawingCanvasRef.current;
|
| 167 |
+
if (!canvas) return;
|
| 168 |
+
|
| 169 |
+
const ctx = canvas.getContext('2d');
|
| 170 |
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 171 |
+
|
| 172 |
+
ctx.save();
|
| 173 |
+
ctx.translate(panOffset.x * scale - scaleOffset.x, panOffset.y * scale - scaleOffset.y);
|
| 174 |
+
ctx.scale(scale, scale);
|
| 175 |
+
|
| 176 |
+
elements.forEach(element => {
|
| 177 |
+
if (element.type === 'pencil') {
|
| 178 |
+
ctx.beginPath();
|
| 179 |
+
ctx.strokeStyle = element.color || currentColor;
|
| 180 |
+
ctx.lineWidth = element.size;
|
| 181 |
+
ctx.lineCap = 'round';
|
| 182 |
+
ctx.lineJoin = 'round';
|
| 183 |
+
|
| 184 |
+
element.points.forEach((point, index) => {
|
| 185 |
+
if (index === 0) {
|
| 186 |
+
ctx.moveTo(point.x, point.y);
|
| 187 |
+
} else {
|
| 188 |
+
ctx.lineTo(point.x, point.y);
|
| 189 |
+
}
|
| 190 |
+
});
|
| 191 |
+
ctx.stroke();
|
| 192 |
+
} else if (element.type === 'line') {
|
| 193 |
+
ctx.beginPath();
|
| 194 |
+
ctx.strokeStyle = element.color || currentColor;
|
| 195 |
+
ctx.lineWidth = 2;
|
| 196 |
+
ctx.moveTo(element.x1, element.y1);
|
| 197 |
+
ctx.lineTo(element.x2, element.y2);
|
| 198 |
+
ctx.stroke();
|
| 199 |
+
} else if (element.type === 'rectangle') {
|
| 200 |
+
ctx.strokeStyle = element.color || currentColor;
|
| 201 |
+
ctx.lineWidth = 2;
|
| 202 |
+
ctx.strokeRect(
|
| 203 |
+
element.x1,
|
| 204 |
+
element.y1,
|
| 205 |
+
element.x2 - element.x1,
|
| 206 |
+
element.y2 - element.y1
|
| 207 |
+
);
|
| 208 |
+
} else if (element.type === 'text') {
|
| 209 |
+
ctx.font = '24px sans-serif';
|
| 210 |
+
ctx.fillStyle = element.color || currentColor;
|
| 211 |
+
ctx.fillText(element.text, element.x1, element.y1);
|
| 212 |
+
}
|
| 213 |
+
});
|
| 214 |
+
|
| 215 |
+
ctx.restore();
|
| 216 |
+
}, [elements, scale, panOffset, scaleOffset, currentColor]);
|
| 217 |
+
React.useEffect(() => {
|
| 218 |
+
drawBackground();
|
| 219 |
+
drawElements();
|
| 220 |
+
}, [drawBackground, drawElements]);
|
| 221 |
+
const ColorPickerButton = () => (
|
| 222 |
+
<div className="relative">
|
| 223 |
+
<button
|
| 224 |
+
onClick={() => setShowColorPicker(!showColorPicker)}
|
| 225 |
+
className="p-2 rounded hover:bg-opacity-80 flex items-center gap-1"
|
| 226 |
+
title="Color Picker"
|
| 227 |
+
>
|
| 228 |
+
<div
|
| 229 |
+
className="w-5 h-5 rounded-full border border-gray-300"
|
| 230 |
+
style={{ backgroundColor: currentColor }}
|
| 231 |
+
/>
|
| 232 |
+
<Palette size={16} color={iconColor} />
|
| 233 |
+
</button>
|
| 234 |
+
{showColorPicker && <ColorPicker />}
|
| 235 |
+
</div>
|
| 236 |
+
);
|
| 237 |
+
|
| 238 |
+
React.useEffect(() => {
|
| 239 |
+
const handleResize = () => {
|
| 240 |
+
if (backgroundCanvasRef.current && drawingCanvasRef.current) {
|
| 241 |
+
backgroundCanvasRef.current.width = window.innerWidth;
|
| 242 |
+
backgroundCanvasRef.current.height = window.innerHeight;
|
| 243 |
+
drawingCanvasRef.current.width = window.innerWidth;
|
| 244 |
+
drawingCanvasRef.current.height = window.innerHeight;
|
| 245 |
+
drawBackground();
|
| 246 |
+
drawElements();
|
| 247 |
+
}
|
| 248 |
+
};
|
| 249 |
+
|
| 250 |
+
window.addEventListener('resize', handleResize);
|
| 251 |
+
handleResize();
|
| 252 |
+
return () => window.removeEventListener('resize', handleResize);
|
| 253 |
+
}, [drawBackground, drawElements]);
|
| 254 |
+
|
| 255 |
+
const toggleDarkMode = () => {
|
| 256 |
+
setDarkMode(prev => !prev);
|
| 257 |
+
};
|
| 258 |
+
|
| 259 |
+
const handleDetectAndShowDownload = () => {
|
| 260 |
+
handleDetectRegions();
|
| 261 |
+
setShowDownloadButton(true);
|
| 262 |
+
setIsMenuOpen(false);
|
| 263 |
+
};
|
| 264 |
+
|
| 265 |
+
const iconColor = darkMode ? "#000000" : "#ffffff"; // Đảo ngược màu icon
|
| 266 |
+
const barBgColor = darkMode ? "bg-white" : "bg-gray-800"; // Giữ nguyên màu nền
|
| 267 |
+
const textColor = darkMode ? 'text-black' : 'text-white'; // Đảo ngược màu text
|
| 268 |
+
const handleColorChange =(color)=>{
|
| 269 |
+
setCurrentColor(color);
|
| 270 |
+
setShowColorPicker(false);
|
| 271 |
+
};
|
| 272 |
+
const handleCustomColorChange = (e) =>{
|
| 273 |
+
const newColor = e.target.value;
|
| 274 |
+
setCustomColor(newColor);
|
| 275 |
+
setCurrentColor(newColor);
|
| 276 |
+
};
|
| 277 |
+
const ColorPicker = () => (
|
| 278 |
+
<div className={`absolute top-full mt-2 p-2 ${barBgColor} rounded-lg shadow-lg`}>
|
| 279 |
+
<div className="grid grid-cols-4 gap-2 mb-2">
|
| 280 |
+
{PRESET_COLORS.map((color, index) => (
|
| 281 |
+
<button
|
| 282 |
+
key={index}
|
| 283 |
+
onClick={() => handleColorChange(color)}
|
| 284 |
+
className="w-6 h-6 rounded-full border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
| 285 |
+
style={{
|
| 286 |
+
backgroundColor: color,
|
| 287 |
+
border: currentColor === color ? '2px solid #3B82F6' : '1px solid #D1D5DB'
|
| 288 |
+
}}
|
| 289 |
+
/>
|
| 290 |
+
))}
|
| 291 |
+
</div>
|
| 292 |
+
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-gray-600">
|
| 293 |
+
<input
|
| 294 |
+
type="color"
|
| 295 |
+
value={customColor}
|
| 296 |
+
onChange={handleCustomColorChange}
|
| 297 |
+
className="w-8 h-8 p-0 border-0 rounded cursor-pointer"
|
| 298 |
+
/>
|
| 299 |
+
<input
|
| 300 |
+
type="text"
|
| 301 |
+
value={customColor.toUpperCase()}
|
| 302 |
+
onChange={(e) => {
|
| 303 |
+
const val = e.target.value;
|
| 304 |
+
if (/^#[0-9A-F]{0,6}$/i.test(val)) {
|
| 305 |
+
setCustomColor(val);
|
| 306 |
+
if (val.length === 7) setCurrentColor(val);
|
| 307 |
+
}
|
| 308 |
+
}}
|
| 309 |
+
className={`w-20 px-1 py-0.5 text-sm rounded ${
|
| 310 |
+
darkMode ? 'bg-gray-700 text-white' : 'bg-white text-black'
|
| 311 |
+
}`}
|
| 312 |
+
placeholder="#000000"
|
| 313 |
+
/>
|
| 314 |
+
</div>
|
| 315 |
+
</div>
|
| 316 |
+
);
|
| 317 |
+
return (
|
| 318 |
+
<div className={`relative w-full h-screen ${darkMode ? 'bg-gray-900' : 'bg-white'}`}>
|
| 319 |
+
{/* Corner Menu Button */}
|
| 320 |
+
<button
|
| 321 |
+
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
| 322 |
+
className={`fixed top-4 left-4 z-20 p-2 ${barBgColor} rounded-lg shadow-lg hover:bg-opacity-80`}
|
| 323 |
+
>
|
| 324 |
+
{isMenuOpen ?
|
| 325 |
+
<X size={24} color={iconColor} /> :
|
| 326 |
+
<Menu size={24} color={iconColor} />
|
| 327 |
+
}
|
| 328 |
+
</button>
|
| 329 |
+
|
| 330 |
+
{/* Corner Menu Panel */}
|
| 331 |
+
{isMenuOpen && (
|
| 332 |
+
<div className={`fixed top-16 left-4 z-20 ${barBgColor} rounded-lg shadow-lg p-4 w-64`}>
|
| 333 |
+
<div className="flex flex-col gap-4">
|
| 334 |
+
{/* Theme Toggle */}
|
| 335 |
+
<button
|
| 336 |
+
onClick={toggleDarkMode}
|
| 337 |
+
className={`w-full flex items-center justify-between p-2 rounded hover:bg-opacity-80 ${darkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-100'}`}
|
| 338 |
+
>
|
| 339 |
+
<span className={`text-sm ${darkMode ? 'text-gray-600' : 'text-white'}`}>
|
| 340 |
+
{darkMode ? 'Light Mode' : 'Dark Mode'}
|
| 341 |
+
</span>
|
| 342 |
+
{darkMode ?
|
| 343 |
+
<Sun size={20} color={iconColor} /> :
|
| 344 |
+
<Moon size={20} color={iconColor} />
|
| 345 |
+
}
|
| 346 |
+
</button>
|
| 347 |
+
|
| 348 |
+
{/* Grid Toggle */}
|
| 349 |
+
<button
|
| 350 |
+
onClick={() => setBackgroundType(prev => prev === 'grid' ? 'none' : 'grid')}
|
| 351 |
+
className={`w-full flex items-center justify-between p-2 rounded hover:bg-opacity-80 ${darkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-100'}`}
|
| 352 |
+
>
|
| 353 |
+
<span className={`text-sm ${darkMode ? 'text-gray-600' : 'text-white'}`}>
|
| 354 |
+
Show Grid
|
| 355 |
+
</span>
|
| 356 |
+
<Grid
|
| 357 |
+
size={20}
|
| 358 |
+
color={iconColor}
|
| 359 |
+
className={backgroundType === 'grid' ? 'opacity-100' : 'opacity-50'}
|
| 360 |
+
/>
|
| 361 |
+
</button>
|
| 362 |
+
<button
|
| 363 |
+
onClick={toggleBackground}
|
| 364 |
+
className={`w-full flex items-center justify-between p-2 rounded hover:bg-opacity-80 ${
|
| 365 |
+
darkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-100'
|
| 366 |
+
}`}
|
| 367 |
+
>
|
| 368 |
+
<span className={`text-sm ${darkMode ? 'text-black' : 'text-white'}`}>
|
| 369 |
+
{backgroundType === 'none' ? 'No Grid' :
|
| 370 |
+
backgroundType === 'grid' ? 'Grid Lines' : 'Grid Dots'}
|
| 371 |
+
</span>
|
| 372 |
+
<Grid
|
| 373 |
+
size={20}
|
| 374 |
+
color={iconColor}
|
| 375 |
+
className={backgroundType !== 'none' ? 'opacity-100' : 'opacity-50'}
|
| 376 |
+
/>
|
| 377 |
+
</button>
|
| 378 |
+
|
| 379 |
+
{backgroundType !== 'none' && (
|
| 380 |
+
<div className="flex flex-col gap-2 p-2">
|
| 381 |
+
<div className="flex items-center justify-between">
|
| 382 |
+
<span className={`text-sm ${darkMode ? 'text-black' : 'text-white'}`}>
|
| 383 |
+
Grid Size
|
| 384 |
+
</span>
|
| 385 |
+
<span className={`text-sm ${darkMode ? 'text-black' : 'text-white'}`}>
|
| 386 |
+
{gridSize}px
|
| 387 |
+
</span>
|
| 388 |
+
</div>
|
| 389 |
+
<input
|
| 390 |
+
type="range"
|
| 391 |
+
min="10"
|
| 392 |
+
max="100"
|
| 393 |
+
value={gridSize}
|
| 394 |
+
onChange={(e) => setGridSize(parseInt(e.target.value))}
|
| 395 |
+
className="w-full h-1 bg-gray-300 rounded-lg appearance-none cursor-pointer"
|
| 396 |
+
/>
|
| 397 |
+
</div>
|
| 398 |
+
)}
|
| 399 |
+
<style jsx global>{`
|
| 400 |
+
input[type="range"] {
|
| 401 |
+
-webkit-appearance: none;
|
| 402 |
+
height: 4px;
|
| 403 |
+
background: ${darkMode ? '#ffffff40' : '#00000040'};
|
| 404 |
+
border-radius: 2px;
|
| 405 |
+
outline: none;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
input[type="range"]::-webkit-slider-thumb {
|
| 409 |
+
-webkit-appearance: none;
|
| 410 |
+
appearance: none;
|
| 411 |
+
width: 12px;
|
| 412 |
+
height: 12px;
|
| 413 |
+
background: ${darkMode ? '#ffffff' : '#000000'};
|
| 414 |
+
border-radius: 50%;
|
| 415 |
+
cursor: pointer;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
input[type="range"]::-moz-range-thumb {
|
| 419 |
+
width: 12px;
|
| 420 |
+
height: 12px;
|
| 421 |
+
background: ${darkMode ? '#ffffff' : '#000000'};
|
| 422 |
+
border-radius: 50%;
|
| 423 |
+
cursor: pointer;
|
| 424 |
+
border: none;
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
input[type="range"]:hover::-webkit-slider-thumb {
|
| 428 |
+
background: #3b82f6;
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
input[type="range"]:hover::-moz-range-thumb {
|
| 432 |
+
background: #3b82f6;
|
| 433 |
+
}
|
| 434 |
+
`}</style>
|
| 435 |
+
{/* Region Detection */}
|
| 436 |
+
<button
|
| 437 |
+
onClick={handleDetectAndShowDownload}
|
| 438 |
+
className={`w-full flex items-center justify-between p-2 rounded hover:bg-opacity-80 ${darkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-100'}`}
|
| 439 |
+
>
|
| 440 |
+
<span className={`text-sm ${darkMode ? 'text-gray-600' : 'text-white'}`}>
|
| 441 |
+
Detect Regions
|
| 442 |
+
</span>
|
| 443 |
+
<Scan size={20} color={iconColor} />
|
| 444 |
+
</button>
|
| 445 |
+
</div>
|
| 446 |
+
</div>
|
| 447 |
+
)}
|
| 448 |
+
|
| 449 |
+
{/* Main Toolbar */}
|
| 450 |
+
<div className={`fixed top-4 left-1/2 transform -translate-x-1/2 flex items-center gap-1 ${barBgColor} rounded-lg shadow-lg p-2 z-10`}>
|
| 451 |
+
<div className="flex items-center gap-1 pr-2 border-r border-gray-200">
|
| 452 |
+
<button onClick={undo} className="p-2 rounded hover:bg-opacity-80" title="Undo">
|
| 453 |
+
<Undo2 size={20} color={iconColor} />
|
| 454 |
+
</button>
|
| 455 |
+
<button onClick={redo} className="p-2 rounded hover:bg-opacity-80" title="Redo">
|
| 456 |
+
<Redo2 size={20} color={iconColor} />
|
| 457 |
+
</button>
|
| 458 |
+
</div>
|
| 459 |
+
|
| 460 |
+
<div className="flex items-center gap-1 px-2">
|
| 461 |
+
<ColorPickerButton />
|
| 462 |
+
<button
|
| 463 |
+
onClick={() => setTool('selection')}
|
| 464 |
+
className={`p-2 rounded ${tool === 'selection' ? 'bg-blue-500' : 'hover:bg-opacity-80'}`}
|
| 465 |
+
title="Selection"
|
| 466 |
+
>
|
| 467 |
+
<Hand size={20} color={tool === 'selection' ? '#ffffff' : iconColor} />
|
| 468 |
+
</button>
|
| 469 |
+
<button
|
| 470 |
+
onClick={() => setTool('line')}
|
| 471 |
+
className={`p-2 rounded ${tool === 'line' ? 'bg-blue-500' : 'hover:bg-opacity-80'}`}
|
| 472 |
+
title="Line"
|
| 473 |
+
>
|
| 474 |
+
<GripHorizontal size={20} color={tool === 'line' ? '#ffffff' : iconColor} />
|
| 475 |
+
</button>
|
| 476 |
+
<button
|
| 477 |
+
onClick={() => setTool('rectangle')}
|
| 478 |
+
className={`p-2 rounded ${tool === 'rectangle' ? 'bg-blue-500' : 'hover:bg-opacity-80'}`}
|
| 479 |
+
title="Rectangle"
|
| 480 |
+
>
|
| 481 |
+
<Square size={20} color={tool === 'rectangle' ? '#ffffff' : iconColor} />
|
| 482 |
+
</button>
|
| 483 |
+
<button
|
| 484 |
+
onClick={() => setTool('pencil')}
|
| 485 |
+
className={`p-2 rounded ${tool === 'pencil' ? 'bg-blue-500' : 'hover:bg-opacity-80'}`}
|
| 486 |
+
title="Pencil"
|
| 487 |
+
>
|
| 488 |
+
<Pencil size={20} color={tool === 'pencil' ? '#ffffff' : iconColor} />
|
| 489 |
+
</button>
|
| 490 |
+
<button
|
| 491 |
+
onClick={() => setTool('eraser')}
|
| 492 |
+
className={`p-2 rounded ${tool === 'eraser' ? 'bg-blue-500' : 'hover:bg-opacity-80'}`}
|
| 493 |
+
title="Eraser"
|
| 494 |
+
>
|
| 495 |
+
<Eraser size={20} color={tool === 'eraser' ? '#ffffff' : iconColor} />
|
| 496 |
+
</button>
|
| 497 |
+
{(tool === 'pencil') && (
|
| 498 |
+
<div className=' flex items-center gap-2 ml-2'>
|
| 499 |
+
<Ruler size={16} color={iconColor} />
|
| 500 |
+
<input
|
| 501 |
+
type="range"
|
| 502 |
+
min="1"
|
| 503 |
+
max="20"
|
| 504 |
+
value={pencilSize}
|
| 505 |
+
onChange={(e) => setPencilSize(parseInt(e.target.value))}
|
| 506 |
+
className="w-24 h-1 bg-gray-300 rounded-lg appearance-none cursor-pointer"
|
| 507 |
+
title="Pencil Size"
|
| 508 |
+
/>
|
| 509 |
+
<span className={'text-sm ${textColor}'}>{pencilSize}px</span>
|
| 510 |
+
</div>
|
| 511 |
+
)}
|
| 512 |
+
</div>
|
| 513 |
+
|
| 514 |
+
<div className="flex items-center gap-1 pl-2 border-l border-gray-200">
|
| 515 |
+
<button
|
| 516 |
+
onClick={() => setScale(1)}
|
| 517 |
+
className="p-2 rounded hover:bg-opacity-80"
|
| 518 |
+
title="Reset View"
|
| 519 |
+
>
|
| 520 |
+
<RotateCcw size={20} color={iconColor} />
|
| 521 |
+
</button>
|
| 522 |
+
<button
|
| 523 |
+
onClick={() => onZoom(-0.1)}
|
| 524 |
+
className="p-2 rounded hover:bg-opacity-80"
|
| 525 |
+
title="Zoom Out"
|
| 526 |
+
>
|
| 527 |
+
<ZoomOut size={20} color={iconColor} />
|
| 528 |
+
</button>
|
| 529 |
+
<span className={`text-sm min-w-[4rem] text-center ${textColor}`}>
|
| 530 |
+
{Math.round(scale * 100)}%
|
| 531 |
+
</span>
|
| 532 |
+
<button
|
| 533 |
+
onClick={() => onZoom(0.1)}
|
| 534 |
+
className="p-2 rounded hover:bg-opacity-80"
|
| 535 |
+
title="Zoom In"
|
| 536 |
+
>
|
| 537 |
+
<ZoomIn size={20} color={iconColor} />
|
| 538 |
+
</button>
|
| 539 |
+
</div>
|
| 540 |
+
</div>
|
| 541 |
+
|
| 542 |
+
{/* Download Button */}
|
| 543 |
+
{showDownloadButton && (
|
| 544 |
+
<button
|
| 545 |
+
onClick={() => {
|
| 546 |
+
handleDownloadRegions();
|
| 547 |
+
setShowDownloadButton(false);
|
| 548 |
+
}}
|
| 549 |
+
className="fixed bottom-8 left-1/2 transform -translate-x-1/2 z-20 px-4 py-2 bg-blue-500 text-white rounded-lg shadow-lg hover:bg-blue-600 flex items-center gap-2"
|
| 550 |
+
>
|
| 551 |
+
<Download size={20} color="#ffffff" />
|
| 552 |
+
Download Detected Regions
|
| 553 |
+
</button>
|
| 554 |
+
)}
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
{/* Canvas Container */}
|
| 558 |
+
<div className="absolute inset-0">
|
| 559 |
+
<canvas
|
| 560 |
+
ref={backgroundCanvasRef}
|
| 561 |
+
className="absolute inset-0 z-0"
|
| 562 |
+
/>
|
| 563 |
+
<canvas
|
| 564 |
+
id="canvas"
|
| 565 |
+
ref={drawingCanvasRef}
|
| 566 |
+
onMouseDown={handleMouseDown}
|
| 567 |
+
onMouseMove={handleMouseMove}
|
| 568 |
+
onMouseUp={handleMouseUp}
|
| 569 |
+
className="absolute inset-0 z-1"
|
| 570 |
+
/>
|
| 571 |
+
</div>
|
| 572 |
+
|
| 573 |
+
{/* Text Area for Writing */}
|
| 574 |
+
{action === "writing" && (
|
| 575 |
+
<textarea
|
| 576 |
+
ref={textAreaRef}
|
| 577 |
+
onBlur={handleBlur}
|
| 578 |
+
style={{
|
| 579 |
+
position: "fixed",
|
| 580 |
+
top: (selectedElement.y1 - 2) * scale + panOffset.y * scale - scaleOffset.y,
|
| 581 |
+
left: selectedElement.x1 * scale + panOffset.x * scale - scaleOffset.x,
|
| 582 |
+
font: `${24 * scale}px sans-serif`,
|
| 583 |
+
margin: 0,
|
| 584 |
+
padding: 0,
|
| 585 |
+
border: 0,
|
| 586 |
+
outline: 0,
|
| 587 |
+
resize: "auto",
|
| 588 |
+
overflow: "hidden",
|
| 589 |
+
whiteSpace: "pre",
|
| 590 |
+
background: "transparent",
|
| 591 |
+
color: darkMode ? 'white' : 'black',
|
| 592 |
+
zIndex: 2,
|
| 593 |
+
}}
|
| 594 |
+
/>
|
| 595 |
+
)}
|
| 596 |
+
</div>
|
| 597 |
+
);
|
| 598 |
+
};
|
| 599 |
+
|
| 600 |
+
export default DrawingBoard;
|
src/app/element-utils.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import rough from "roughjs/bundled/rough.esm";
|
| 2 |
+
import getStroke from "perfect-freehand";
|
| 3 |
+
|
| 4 |
+
const generator = rough.generator();
|
| 5 |
+
|
| 6 |
+
export const createElement = (id, x1, y1, x2, y2, type, pencilSize = 3) => {
|
| 7 |
+
switch (type) {
|
| 8 |
+
case "line":
|
| 9 |
+
case "rectangle":
|
| 10 |
+
const roughElement =
|
| 11 |
+
type === "line"
|
| 12 |
+
? generator.line(x1, y1, x2, y2)
|
| 13 |
+
: generator.rectangle(x1, y1, x2 - x1, y2 - y1);
|
| 14 |
+
return { id, x1, y1, x2, y2, type, roughElement };
|
| 15 |
+
case "pencil":
|
| 16 |
+
return { id, type, points: [{ x: x1, y: y1, isErased: false }], size: pencilSize };
|
| 17 |
+
case "text":
|
| 18 |
+
return { id, type, x1, y1, x2, y2, text: "" };
|
| 19 |
+
case "capture":
|
| 20 |
+
return { id, type, x1, y1, x2, y2 };
|
| 21 |
+
default:
|
| 22 |
+
throw new Error(`Type not recognised: ${type}`);
|
| 23 |
+
}
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
export const isPointNearEraser = (x, y, eraserX, eraserY, eraserSize) => {
|
| 27 |
+
const distance = Math.sqrt(Math.pow(x - eraserX, 2) + Math.pow(y - eraserY, 2));
|
| 28 |
+
return distance <= eraserSize;
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
const nearPoint = (x, y, x1, y1, name) => {
|
| 32 |
+
return Math.abs(x - x1) < 5 && Math.abs(y - y1) < 5 ? name : null;
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
const onLine = (x1, y1, x2, y2, x, y, maxDistance = 1) => {
|
| 36 |
+
const a = { x: x1, y: y1 };
|
| 37 |
+
const b = { x: x2, y: y2 };
|
| 38 |
+
const c = { x, y };
|
| 39 |
+
const offset = distance(a, b) - (distance(a, c) + distance(b, c));
|
| 40 |
+
return Math.abs(offset) < maxDistance ? "inside" : null;
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
const positionWithinElement = (x, y, element) => {
|
| 44 |
+
const { type, x1, x2, y1, y2 } = element;
|
| 45 |
+
switch (type) {
|
| 46 |
+
case "line":
|
| 47 |
+
const on = onLine(x1, y1, x2, y2, x, y);
|
| 48 |
+
const start = nearPoint(x, y, x1, y1, "start");
|
| 49 |
+
const end = nearPoint(x, y, x2, y2, "end");
|
| 50 |
+
return start || end || on;
|
| 51 |
+
case "rectangle":
|
| 52 |
+
const topLeft = nearPoint(x, y, x1, y1, "tl");
|
| 53 |
+
const topRight = nearPoint(x, y, x2, y1, "tr");
|
| 54 |
+
const bottomLeft = nearPoint(x, y, x1, y2, "bl");
|
| 55 |
+
const bottomRight = nearPoint(x, y, x2, y2, "br");
|
| 56 |
+
const inside = x >= x1 && x <= x2 && y >= y1 && y <= y2 ? "inside" : null;
|
| 57 |
+
return topLeft || topRight || bottomLeft || bottomRight || inside;
|
| 58 |
+
case "pencil":
|
| 59 |
+
const betweenAnyPoint = element.points.some((point, index) => {
|
| 60 |
+
const nextPoint = element.points[index + 1];
|
| 61 |
+
if (!nextPoint) return false;
|
| 62 |
+
return onLine(point.x, point.y, nextPoint.x, nextPoint.y, x, y, 5) != null;
|
| 63 |
+
});
|
| 64 |
+
return betweenAnyPoint ? "inside" : null;
|
| 65 |
+
case "text":
|
| 66 |
+
return x >= x1 && x <= x2 && y >= y1 && y <= y2 ? "inside" : null;
|
| 67 |
+
default:
|
| 68 |
+
throw new Error(`Type not recognised: ${type}`);
|
| 69 |
+
}
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
export const distance = (a, b) => Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
|
| 73 |
+
|
| 74 |
+
export const getElementAtPosition = (x, y, elements) => {
|
| 75 |
+
return elements
|
| 76 |
+
.map(element => ({ ...element, position: positionWithinElement(x, y, element) }))
|
| 77 |
+
.find(element => element.position !== null);
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
export const adjustElementCoordinates = element => {
|
| 81 |
+
const { type, x1, y1, x2, y2 } = element;
|
| 82 |
+
if (type === "rectangle") {
|
| 83 |
+
const minX = Math.min(x1, x2);
|
| 84 |
+
const maxX = Math.max(x1, x2);
|
| 85 |
+
const minY = Math.min(y1, y2);
|
| 86 |
+
const maxY = Math.max(y1, y2);
|
| 87 |
+
return { x1: minX, y1: minY, x2: maxX, y2: maxY };
|
| 88 |
+
} else {
|
| 89 |
+
if (x1 < x2 || (x1 === x2 && y1 < y2)) {
|
| 90 |
+
return { x1, y1, x2, y2 };
|
| 91 |
+
} else {
|
| 92 |
+
return { x1: x2, y1: y2, x2: x1, y2: y1 };
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
export const cursorForPosition = position => {
|
| 98 |
+
switch (position) {
|
| 99 |
+
case "tl":
|
| 100 |
+
case "br":
|
| 101 |
+
case "start":
|
| 102 |
+
case "end":
|
| 103 |
+
return "nwse-resize";
|
| 104 |
+
case "tr":
|
| 105 |
+
case "bl":
|
| 106 |
+
return "nesw-resize";
|
| 107 |
+
default:
|
| 108 |
+
return "move";
|
| 109 |
+
}
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
export const resizedCoordinates = (clientX, clientY, position, coordinates) => {
|
| 113 |
+
const { x1, y1, x2, y2 } = coordinates;
|
| 114 |
+
switch (position) {
|
| 115 |
+
case "tl":
|
| 116 |
+
case "start":
|
| 117 |
+
return { x1: clientX, y1: clientY, x2, y2 };
|
| 118 |
+
case "tr":
|
| 119 |
+
return { x1, y1: clientY, x2: clientX, y2 };
|
| 120 |
+
case "bl":
|
| 121 |
+
return { x1: clientX, y1, x2, y2: clientY };
|
| 122 |
+
case "br":
|
| 123 |
+
case "end":
|
| 124 |
+
return { x1, y1, x2: clientX, y2: clientY };
|
| 125 |
+
default:
|
| 126 |
+
return null;
|
| 127 |
+
}
|
| 128 |
+
};
|
| 129 |
+
|
| 130 |
+
export const getSvgPathFromStroke = stroke => {
|
| 131 |
+
if (!stroke.length) return "";
|
| 132 |
+
|
| 133 |
+
const d = stroke.reduce(
|
| 134 |
+
(acc, [x0, y0], i, arr) => {
|
| 135 |
+
const [x1, y1] = arr[(i + 1) % arr.length];
|
| 136 |
+
acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2);
|
| 137 |
+
return acc;
|
| 138 |
+
},
|
| 139 |
+
["M", ...stroke[0], "Q"]
|
| 140 |
+
);
|
| 141 |
+
|
| 142 |
+
d.push("Z");
|
| 143 |
+
return d.join(" ");
|
| 144 |
+
};
|
| 145 |
+
|
| 146 |
+
export const drawElement = (roughCanvas, context, element) => {
|
| 147 |
+
switch (element.type) {
|
| 148 |
+
case "line":
|
| 149 |
+
case "rectangle":
|
| 150 |
+
roughCanvas.draw(element.roughElement);
|
| 151 |
+
break;
|
| 152 |
+
case "pencil":
|
| 153 |
+
const nonErasedSegments = [];
|
| 154 |
+
let currentSegment = [];
|
| 155 |
+
|
| 156 |
+
element.points.forEach((point, index) => {
|
| 157 |
+
if (!point.isErased) {
|
| 158 |
+
currentSegment.push([point.x, point.y]);
|
| 159 |
+
} else if (currentSegment.length > 0) {
|
| 160 |
+
nonErasedSegments.push([...currentSegment]);
|
| 161 |
+
currentSegment = [];
|
| 162 |
+
}
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
if (currentSegment.length > 0) {
|
| 166 |
+
nonErasedSegments.push(currentSegment);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
nonErasedSegments.forEach(segment => {
|
| 170 |
+
if (segment.length > 1) {
|
| 171 |
+
const stroke = getSvgPathFromStroke(getStroke(segment, {
|
| 172 |
+
size: element.size || 3,
|
| 173 |
+
thinning: 0.5,
|
| 174 |
+
smoothing: 0.5,
|
| 175 |
+
streamline: 0.5,
|
| 176 |
+
}));
|
| 177 |
+
context.fillStyle = '#000';
|
| 178 |
+
context.fill(new Path2D(stroke));
|
| 179 |
+
}
|
| 180 |
+
});
|
| 181 |
+
break;
|
| 182 |
+
case "text":
|
| 183 |
+
context.textBaseline = "top";
|
| 184 |
+
context.font = "24px sans-serif";
|
| 185 |
+
context.fillText(element.text, element.x1, element.y1);
|
| 186 |
+
break;
|
| 187 |
+
case "capture":
|
| 188 |
+
context.save();
|
| 189 |
+
context.strokeStyle = "#000000";
|
| 190 |
+
context.setLineDash([5, 5]);
|
| 191 |
+
context.lineWidth = 1;
|
| 192 |
+
context.strokeRect(
|
| 193 |
+
element.x1,
|
| 194 |
+
element.y1,
|
| 195 |
+
element.x2 - element.x1,
|
| 196 |
+
element.y2 - element.y1
|
| 197 |
+
);
|
| 198 |
+
context.restore();
|
| 199 |
+
break;
|
| 200 |
+
default:
|
| 201 |
+
throw new Error(`Type not recognised: ${element.type}`);
|
| 202 |
+
}
|
| 203 |
+
};
|
| 204 |
+
|
| 205 |
+
export const adjustmentRequired = type => ["line", "rectangle"].includes(type);
|
| 206 |
+
|
| 207 |
+
export const calculateBoundsForElements = (elementsList) => {
|
| 208 |
+
if (elementsList.length === 0) return null;
|
| 209 |
+
|
| 210 |
+
let minX = Infinity;
|
| 211 |
+
let minY = Infinity;
|
| 212 |
+
let maxX = -Infinity;
|
| 213 |
+
let maxY = -Infinity;
|
| 214 |
+
|
| 215 |
+
elementsList.forEach(element => {
|
| 216 |
+
if (element.type === "text") {
|
| 217 |
+
minX = Math.min(minX, element.x1);
|
| 218 |
+
minY = Math.min(minY, element.y1);
|
| 219 |
+
maxX = Math.max(maxX, element.x2);
|
| 220 |
+
maxY = Math.max(maxY, element.y2);
|
| 221 |
+
} else if (element.type === "pencil") {
|
| 222 |
+
element.points.forEach(point => {
|
| 223 |
+
minX = Math.min(minX, point.x);
|
| 224 |
+
minY = Math.min(minY, point.y);
|
| 225 |
+
maxX = Math.max(maxX, point.x);
|
| 226 |
+
maxY = Math.max(maxY, point.y);
|
| 227 |
+
});
|
| 228 |
+
} else if (element.type === "line" || element.type === "rectangle") {
|
| 229 |
+
minX = Math.min(minX, element.x1, element.x2);
|
| 230 |
+
minY = Math.min(minY, element.y1, element.y2);
|
| 231 |
+
maxX = Math.max(maxX, element.x1, element.x2);
|
| 232 |
+
maxY = Math.max(maxY, element.y2, element.y2);
|
| 233 |
+
}
|
| 234 |
+
});
|
| 235 |
+
|
| 236 |
+
const padding = 20;
|
| 237 |
+
return {
|
| 238 |
+
x1: minX - padding,
|
| 239 |
+
y1: minY - padding,
|
| 240 |
+
x2: maxX + padding,
|
| 241 |
+
y2: maxY + padding
|
| 242 |
+
};
|
| 243 |
+
};
|
src/app/favicon.ico
ADDED
|
|
src/app/fonts/GeistMonoVF.woff
ADDED
|
Binary file (67.9 kB). View file
|
|
|
src/app/fonts/GeistVF.woff
ADDED
|
Binary file (66.3 kB). View file
|
|
|
src/app/globals.css
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--background: #ffffff;
|
| 7 |
+
--foreground: #171717;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
@media (prefers-color-scheme: dark) {
|
| 11 |
+
:root {
|
| 12 |
+
--background: #0a0a0a;
|
| 13 |
+
--foreground: #ededed;
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
body {
|
| 18 |
+
color: var(--foreground);
|
| 19 |
+
background: var(--background);
|
| 20 |
+
font-family: Arial, Helvetica, sans-serif;
|
| 21 |
+
}
|
src/app/handleMouse.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createElement, getElementAtPosition, adjustElementCoordinates, cursorForPosition, resizedCoordinates, adjustmentRequired } from './element-utils';
|
| 2 |
+
|
| 3 |
+
export const createMouseHandlers = ({
|
| 4 |
+
action,
|
| 5 |
+
setAction,
|
| 6 |
+
tool,
|
| 7 |
+
setTool,
|
| 8 |
+
elements,
|
| 9 |
+
setElements,
|
| 10 |
+
selectedElement,
|
| 11 |
+
setSelectedElement,
|
| 12 |
+
panOffset,
|
| 13 |
+
setPanOffset,
|
| 14 |
+
scale,
|
| 15 |
+
scaleOffset,
|
| 16 |
+
startPanMousePosition,
|
| 17 |
+
setStartPanMousePosition,
|
| 18 |
+
pressedKeys,
|
| 19 |
+
pencilSize,
|
| 20 |
+
setCaptureArea,
|
| 21 |
+
isDrawing,
|
| 22 |
+
setIsDrawing
|
| 23 |
+
}) => {
|
| 24 |
+
const getMouseCoordinates = event => {
|
| 25 |
+
const clientX = (event.clientX - panOffset.x * scale + scaleOffset.x) / scale;
|
| 26 |
+
const clientY = (event.clientY - panOffset.y * scale + scaleOffset.y) / scale;
|
| 27 |
+
return { clientX, clientY };
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
const updateElement = (id, x1, y1, x2, y2, type, options) => {
|
| 31 |
+
const elementsCopy = [...elements];
|
| 32 |
+
|
| 33 |
+
switch (type) {
|
| 34 |
+
case "line":
|
| 35 |
+
case "rectangle":
|
| 36 |
+
elementsCopy[id] = createElement(id, x1, y1, x2, y2, type);
|
| 37 |
+
elementsCopy[id].size = pencilSize;
|
| 38 |
+
break;
|
| 39 |
+
case "pencil":
|
| 40 |
+
elementsCopy[id].points = [...elementsCopy[id].points, { x: x2, y: y2 }];
|
| 41 |
+
break;
|
| 42 |
+
case "text":
|
| 43 |
+
const textWidth = document
|
| 44 |
+
.getElementById("canvas")
|
| 45 |
+
.getContext("2d")
|
| 46 |
+
.measureText(options.text).width;
|
| 47 |
+
const textHeight = 24;
|
| 48 |
+
elementsCopy[id] = {
|
| 49 |
+
...createElement(id, x1, y1, x1 + textWidth, y1 + textHeight, type),
|
| 50 |
+
text: options.text,
|
| 51 |
+
};
|
| 52 |
+
break;
|
| 53 |
+
default:
|
| 54 |
+
throw new Error(`Type not recognised: ${type}`);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
setElements(elementsCopy, true);
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
const handleMouseDown = event => {
|
| 61 |
+
if (action === "writing") return;
|
| 62 |
+
|
| 63 |
+
const { clientX, clientY } = getMouseCoordinates(event);
|
| 64 |
+
|
| 65 |
+
if (event.button === 1 || pressedKeys.has(" ")) {
|
| 66 |
+
setAction("panning");
|
| 67 |
+
setStartPanMousePosition({ x: clientX, y: clientY });
|
| 68 |
+
return;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
setIsDrawing(true);
|
| 72 |
+
|
| 73 |
+
if (tool === "capture") {
|
| 74 |
+
const id = elements.length;
|
| 75 |
+
const element = createElement(id, clientX, clientY, clientX, clientY, "capture");
|
| 76 |
+
setElements(prevState => [...prevState, element]);
|
| 77 |
+
setSelectedElement(element);
|
| 78 |
+
setAction("drawing");
|
| 79 |
+
return;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
if (tool === "eraser") {
|
| 83 |
+
setAction("erasing");
|
| 84 |
+
const elementToErase = getElementAtPosition(clientX, clientY, elements);
|
| 85 |
+
if (elementToErase) {
|
| 86 |
+
setElements(prevState =>
|
| 87 |
+
prevState.filter(el => el.id !== elementToErase.id)
|
| 88 |
+
);
|
| 89 |
+
}
|
| 90 |
+
return;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
if (tool === "selection") {
|
| 94 |
+
const element = getElementAtPosition(clientX, clientY, elements);
|
| 95 |
+
if (element) {
|
| 96 |
+
if (element.type === "pencil") {
|
| 97 |
+
const xOffsets = element.points.map(point => clientX - point.x);
|
| 98 |
+
const yOffsets = element.points.map(point => clientY - point.y);
|
| 99 |
+
setSelectedElement({ ...element, xOffsets, yOffsets });
|
| 100 |
+
} else {
|
| 101 |
+
const offsetX = clientX - element.x1;
|
| 102 |
+
const offsetY = clientY - element.y1;
|
| 103 |
+
setSelectedElement({ ...element, offsetX, offsetY });
|
| 104 |
+
}
|
| 105 |
+
setElements(prevState => prevState);
|
| 106 |
+
|
| 107 |
+
if (element.position === "inside") {
|
| 108 |
+
setAction("moving");
|
| 109 |
+
} else {
|
| 110 |
+
setAction("resizing");
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
} else {
|
| 114 |
+
const id = elements.length;
|
| 115 |
+
const element = createElement(id, clientX, clientY, clientX, clientY, tool, pencilSize);
|
| 116 |
+
setElements(prevState => [...prevState, element]);
|
| 117 |
+
setSelectedElement(element);
|
| 118 |
+
setAction(tool === "text" ? "writing" : "drawing");
|
| 119 |
+
}
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
const handleMouseMove = event => {
|
| 123 |
+
const { clientX, clientY } = getMouseCoordinates(event);
|
| 124 |
+
|
| 125 |
+
if (action === "panning") {
|
| 126 |
+
const deltaX = clientX - startPanMousePosition.x;
|
| 127 |
+
const deltaY = clientY - startPanMousePosition.y;
|
| 128 |
+
setPanOffset({
|
| 129 |
+
x: panOffset.x + deltaX,
|
| 130 |
+
y: panOffset.y + deltaY,
|
| 131 |
+
});
|
| 132 |
+
return;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
if (action === "erasing") {
|
| 136 |
+
const elementToErase = getElementAtPosition(clientX, clientY, elements);
|
| 137 |
+
if (elementToErase) {
|
| 138 |
+
setElements(prevState =>
|
| 139 |
+
prevState.filter(el => el.id !== elementToErase.id)
|
| 140 |
+
);
|
| 141 |
+
}
|
| 142 |
+
return;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
if (tool === "selection") {
|
| 146 |
+
const element = getElementAtPosition(clientX, clientY, elements);
|
| 147 |
+
event.target.style.cursor = element ? cursorForPosition(element.position) : "default";
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
if (action === "drawing") {
|
| 151 |
+
const index = elements.length - 1;
|
| 152 |
+
const { x1, y1 } = elements[index];
|
| 153 |
+
|
| 154 |
+
if (tool === "capture") {
|
| 155 |
+
const element = elements[index];
|
| 156 |
+
const updatedElement = {
|
| 157 |
+
...element,
|
| 158 |
+
x2: clientX,
|
| 159 |
+
y2: clientY
|
| 160 |
+
};
|
| 161 |
+
const elementsCopy = [...elements];
|
| 162 |
+
elementsCopy[index] = updatedElement;
|
| 163 |
+
setElements(elementsCopy, true);
|
| 164 |
+
setCaptureArea(updatedElement);
|
| 165 |
+
} else {
|
| 166 |
+
updateElement(index, x1, y1, clientX, clientY, tool);
|
| 167 |
+
}
|
| 168 |
+
} else if (action === "moving") {
|
| 169 |
+
if (selectedElement.type === "pencil") {
|
| 170 |
+
const newPoints = selectedElement.points.map((_, index) => ({
|
| 171 |
+
x: clientX - selectedElement.xOffsets[index],
|
| 172 |
+
y: clientY - selectedElement.yOffsets[index],
|
| 173 |
+
}));
|
| 174 |
+
const elementsCopy = [...elements];
|
| 175 |
+
elementsCopy[selectedElement.id] = {
|
| 176 |
+
...elementsCopy[selectedElement.id],
|
| 177 |
+
points: newPoints,
|
| 178 |
+
};
|
| 179 |
+
setElements(elementsCopy, true);
|
| 180 |
+
} else {
|
| 181 |
+
const { id, x1, x2, y1, y2, type, offsetX, offsetY } = selectedElement;
|
| 182 |
+
const width = x2 - x1;
|
| 183 |
+
const height = y2 - y1;
|
| 184 |
+
const newX1 = clientX - offsetX;
|
| 185 |
+
const newY1 = clientY - offsetY;
|
| 186 |
+
const options = type === "text" ? { text: selectedElement.text } : {};
|
| 187 |
+
updateElement(id, newX1, newY1, newX1 + width, newY1 + height, type, options);
|
| 188 |
+
}
|
| 189 |
+
} else if (action === "resizing") {
|
| 190 |
+
const { id, type, position, ...coordinates } = selectedElement;
|
| 191 |
+
const { x1, y1, x2, y2 } = resizedCoordinates(clientX, clientY, position, coordinates);
|
| 192 |
+
updateElement(id, x1, y1, x2, y2, type);
|
| 193 |
+
}
|
| 194 |
+
};
|
| 195 |
+
|
| 196 |
+
const handleMouseUp = event => {
|
| 197 |
+
setIsDrawing(false);
|
| 198 |
+
const { clientX, clientY } = getMouseCoordinates(event);
|
| 199 |
+
|
| 200 |
+
if (selectedElement) {
|
| 201 |
+
if (
|
| 202 |
+
selectedElement.type === "text" &&
|
| 203 |
+
clientX - selectedElement.offsetX === selectedElement.x1 &&
|
| 204 |
+
clientY - selectedElement.offsetY === selectedElement.y1
|
| 205 |
+
) {
|
| 206 |
+
setAction("writing");
|
| 207 |
+
return;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
const index = selectedElement.id;
|
| 211 |
+
const { id, type } = elements[index];
|
| 212 |
+
if ((action === "drawing" || action === "resizing") && adjustmentRequired(type)) {
|
| 213 |
+
const { x1, y1, x2, y2 } = adjustElementCoordinates(elements[index]);
|
| 214 |
+
updateElement(id, x1, y1, x2, y2, type);
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
if (action === "drawing") {
|
| 219 |
+
console.log("Hoàn thành một nét vẽ");
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
if (action === "writing") return;
|
| 223 |
+
|
| 224 |
+
setAction("none");
|
| 225 |
+
setSelectedElement(null);
|
| 226 |
+
};
|
| 227 |
+
|
| 228 |
+
const handleBlur = event => {
|
| 229 |
+
const { id, x1, y1, type } = selectedElement;
|
| 230 |
+
setAction("none");
|
| 231 |
+
setSelectedElement(null);
|
| 232 |
+
updateElement(id, x1, y1, null, null, type, { text: event.target.value });
|
| 233 |
+
};
|
| 234 |
+
|
| 235 |
+
return {
|
| 236 |
+
handleMouseDown,
|
| 237 |
+
handleMouseMove,
|
| 238 |
+
handleMouseUp,
|
| 239 |
+
handleBlur
|
| 240 |
+
};
|
| 241 |
+
};
|
src/app/layout.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import localFont from "next/font/local";
|
| 2 |
+
import "./globals.css";
|
| 3 |
+
|
| 4 |
+
const geistSans = localFont({
|
| 5 |
+
src: "./fonts/GeistVF.woff",
|
| 6 |
+
variable: "--font-geist-sans",
|
| 7 |
+
weight: "100 900",
|
| 8 |
+
});
|
| 9 |
+
const geistMono = localFont({
|
| 10 |
+
src: "./fonts/GeistMonoVF.woff",
|
| 11 |
+
variable: "--font-geist-mono",
|
| 12 |
+
weight: "100 900",
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
export const metadata = {
|
| 16 |
+
title: "Create Next App",
|
| 17 |
+
description: "Generated by create next app",
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
export default function RootLayout({ children }) {
|
| 21 |
+
return (
|
| 22 |
+
<html lang="en">
|
| 23 |
+
<body
|
| 24 |
+
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
| 25 |
+
>
|
| 26 |
+
{children}
|
| 27 |
+
</body>
|
| 28 |
+
</html>
|
| 29 |
+
);
|
| 30 |
+
}
|
src/app/page.js
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
| 3 |
+
import rough from "roughjs/bundled/rough.esm";
|
| 4 |
+
import DrawingBoard from './DrawingBoard';
|
| 5 |
+
import {
|
| 6 |
+
createElement,
|
| 7 |
+
drawElement,
|
| 8 |
+
} from './element-utils';
|
| 9 |
+
import { createMouseHandlers } from './handleMouse';
|
| 10 |
+
const captureRegionToPNG = (canvas, region, elements, scale, panOffset, scaleOffset) => {
|
| 11 |
+
const tempCanvas = document.createElement('canvas');
|
| 12 |
+
const ctx = tempCanvas.getContext('2d');
|
| 13 |
+
|
| 14 |
+
const width = (region.bounds.x2 - region.bounds.x1);
|
| 15 |
+
const height = (region.bounds.y2 - region.bounds.y1);
|
| 16 |
+
|
| 17 |
+
tempCanvas.width = width * scale;
|
| 18 |
+
tempCanvas.height = height * scale;
|
| 19 |
+
|
| 20 |
+
// Fill white background
|
| 21 |
+
ctx.fillStyle = 'white';
|
| 22 |
+
ctx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);
|
| 23 |
+
|
| 24 |
+
ctx.save();
|
| 25 |
+
ctx.scale(scale, scale);
|
| 26 |
+
ctx.translate(-region.bounds.x1, -region.bounds.y1);
|
| 27 |
+
|
| 28 |
+
const mainCanvas = canvas.getContext('2d');
|
| 29 |
+
const roughCanvas = rough.canvas(tempCanvas);
|
| 30 |
+
ctx.translate(panOffset.x, panOffset.y);
|
| 31 |
+
|
| 32 |
+
elements.forEach(element => {
|
| 33 |
+
if (isElementInRegion(element, region.bounds)) {
|
| 34 |
+
drawElement(roughCanvas, ctx, element);
|
| 35 |
+
}
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
ctx.restore();
|
| 39 |
+
|
| 40 |
+
return tempCanvas.toDataURL('image/png');
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
// Update the downloadRegionImage function:
|
| 44 |
+
const downloadRegionImage = (dataUrl, regionId) => {
|
| 45 |
+
const link = document.createElement('a');
|
| 46 |
+
link.href = dataUrl;
|
| 47 |
+
link.download = `region-${regionId}.png`; // Changed extension to .png
|
| 48 |
+
document.body.appendChild(link);
|
| 49 |
+
link.click();
|
| 50 |
+
document.body.removeChild(link);
|
| 51 |
+
};
|
| 52 |
+
const isElementInRegion = (element, bounds) => {
|
| 53 |
+
if (element.type === 'pencil') {
|
| 54 |
+
return element.points.some(point =>
|
| 55 |
+
point.x >= bounds.x1 && point.x <= bounds.x2 &&
|
| 56 |
+
point.y >= bounds.y1 && point.y <= bounds.y2
|
| 57 |
+
);
|
| 58 |
+
} else {
|
| 59 |
+
const elementBounds = {
|
| 60 |
+
minX: Math.min(element.x1, element.x2),
|
| 61 |
+
maxX: Math.max(element.x1, element.x2),
|
| 62 |
+
minY: Math.min(element.y1, element.y2),
|
| 63 |
+
maxY: Math.max(element.y1, element.y2)
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
return !(elementBounds.maxX < bounds.x1 ||
|
| 67 |
+
elementBounds.minX > bounds.x2 ||
|
| 68 |
+
elementBounds.maxY < bounds.y1 ||
|
| 69 |
+
elementBounds.minY > bounds.y2);
|
| 70 |
+
}
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
const downloadAllRegions = (canvas, regions, elements, scale, panOffset, scaleOffset) => {
|
| 75 |
+
regions.forEach(region => {
|
| 76 |
+
const dataUrl = captureRegionToPNG(canvas, region, elements, scale, panOffset, scaleOffset);
|
| 77 |
+
downloadRegionImage(dataUrl, region.id);
|
| 78 |
+
});
|
| 79 |
+
};
|
| 80 |
+
const isPixelNonTransparent = (pixel) => {
|
| 81 |
+
return pixel.a > 0;
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
const isWithinBounds = (x, y, width, height) => {
|
| 85 |
+
return x >= 0 && x < width && y >= 0 && y < height;
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
const getPixel = (imageData, x, y) => {
|
| 89 |
+
const index = (y * imageData.width + x) * 4;
|
| 90 |
+
return imageData.data[index + 3] > 0; // Only check alpha channel for performance
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
// Use Set for faster lookups and Int32Array for coordinates
|
| 94 |
+
const floodFill = (imageData, startX, startY, visited) => {
|
| 95 |
+
const width = imageData.width;
|
| 96 |
+
const height = imageData.height;
|
| 97 |
+
const queue = new Int32Array(width * height * 2); // Pre-allocate queue
|
| 98 |
+
let queueStart = 0;
|
| 99 |
+
let queueEnd = 2;
|
| 100 |
+
queue[0] = startX;
|
| 101 |
+
queue[1] = startY;
|
| 102 |
+
|
| 103 |
+
const region = {
|
| 104 |
+
points: [],
|
| 105 |
+
minX: startX,
|
| 106 |
+
maxX: startX,
|
| 107 |
+
minY: startY,
|
| 108 |
+
maxY: startY
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
// Optimize bounds checking
|
| 112 |
+
const isWithinBounds = (x, y) => x >= 0 && x < width && y >= 0 && y < height;
|
| 113 |
+
|
| 114 |
+
// Pre-calculate neighbor offsets
|
| 115 |
+
const neighborOffsets = [
|
| 116 |
+
[1, 0], [-1, 0],
|
| 117 |
+
[0, 1], [0, -1],
|
| 118 |
+
[1, 1], [-1, -1],
|
| 119 |
+
[1, -1], [-1, 1]
|
| 120 |
+
];
|
| 121 |
+
|
| 122 |
+
while (queueStart < queueEnd) {
|
| 123 |
+
const x = queue[queueStart];
|
| 124 |
+
const y = queue[queueStart + 1];
|
| 125 |
+
queueStart += 2;
|
| 126 |
+
|
| 127 |
+
const key = `${x},${y}`;
|
| 128 |
+
if (visited.has(key)) continue;
|
| 129 |
+
|
| 130 |
+
if (!getPixel(imageData, x, y)) continue;
|
| 131 |
+
|
| 132 |
+
visited.add(key);
|
| 133 |
+
region.points.push({ x, y });
|
| 134 |
+
|
| 135 |
+
// Use Math.min/max for bounds tracking
|
| 136 |
+
region.minX = Math.min(region.minX, x);
|
| 137 |
+
region.maxX = Math.max(region.maxX, x);
|
| 138 |
+
region.minY = Math.min(region.minY, y);
|
| 139 |
+
region.maxY = Math.max(region.maxY, y);
|
| 140 |
+
|
| 141 |
+
// Check neighbors using pre-calculated offsets
|
| 142 |
+
for (const [dx, dy] of neighborOffsets) {
|
| 143 |
+
const nx = x + dx;
|
| 144 |
+
const ny = y + dy;
|
| 145 |
+
if (isWithinBounds(nx, ny) && !visited.has(`${nx},${ny}`)) {
|
| 146 |
+
queue[queueEnd] = nx;
|
| 147 |
+
queue[queueEnd + 1] = ny;
|
| 148 |
+
queueEnd += 2;
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
return region;
|
| 154 |
+
};
|
| 155 |
+
|
| 156 |
+
// Optimize distance calculation
|
| 157 |
+
const calculateRegionDistance = (region1, region2) => {
|
| 158 |
+
// Quick overlap check
|
| 159 |
+
const xOverlap = !(region1.maxX < region2.minX || region1.minX > region2.maxX);
|
| 160 |
+
const yOverlap = !(region1.maxY < region2.minY || region1.minY > region2.maxY);
|
| 161 |
+
|
| 162 |
+
if (xOverlap && yOverlap) return 0;
|
| 163 |
+
|
| 164 |
+
// Calculate distance only when necessary
|
| 165 |
+
const dx = !xOverlap ? Math.min(
|
| 166 |
+
Math.abs(region1.maxX - region2.minX),
|
| 167 |
+
Math.abs(region1.minX - region2.maxX)
|
| 168 |
+
) : 0;
|
| 169 |
+
|
| 170 |
+
const dy = !yOverlap ? Math.min(
|
| 171 |
+
Math.abs(region1.maxY - region2.minY),
|
| 172 |
+
Math.abs(region1.minY - region2.maxY)
|
| 173 |
+
) : 0;
|
| 174 |
+
|
| 175 |
+
return Math.sqrt(dx * dx + dy * dy);
|
| 176 |
+
};
|
| 177 |
+
|
| 178 |
+
// Optimize region merging using Set
|
| 179 |
+
const mergeRegions = (region1, region2) => ({
|
| 180 |
+
points: [...region1.points, ...region2.points],
|
| 181 |
+
minX: Math.min(region1.minX, region2.minX),
|
| 182 |
+
maxX: Math.max(region1.maxX, region2.maxX),
|
| 183 |
+
minY: Math.min(region1.minY, region2.minY),
|
| 184 |
+
maxY: Math.max(region1.maxY, region2.maxY)
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
const detectRegions = (canvas, elements, panOffset, scale, scaleOffset, minRegionSize = 100, groupingDistance = 80) => {
|
| 188 |
+
// Calculate bounds only once
|
| 189 |
+
const bounds = elements.reduce((acc, element) => {
|
| 190 |
+
if (element.type === 'pencil') {
|
| 191 |
+
element.points.forEach(point => {
|
| 192 |
+
acc.minX = Math.min(acc.minX, point.x);
|
| 193 |
+
acc.minY = Math.min(acc.minY, point.y);
|
| 194 |
+
acc.maxX = Math.max(acc.maxX, point.x);
|
| 195 |
+
acc.maxY = Math.max(acc.maxY, point.y);
|
| 196 |
+
});
|
| 197 |
+
} else {
|
| 198 |
+
const { x1, y1, x2, y2 } = element;
|
| 199 |
+
acc.minX = Math.min(acc.minX, x1, x2);
|
| 200 |
+
acc.minY = Math.min(acc.minY, y1, y2);
|
| 201 |
+
acc.maxX = Math.max(acc.maxX, x1, x2);
|
| 202 |
+
acc.maxY = Math.max(acc.maxY, y1, y2);
|
| 203 |
+
}
|
| 204 |
+
return acc;
|
| 205 |
+
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
|
| 206 |
+
|
| 207 |
+
// Add padding
|
| 208 |
+
const padding = 100;
|
| 209 |
+
bounds.minX -= padding;
|
| 210 |
+
bounds.minY -= padding;
|
| 211 |
+
bounds.maxX += padding;
|
| 212 |
+
bounds.maxY += padding;
|
| 213 |
+
|
| 214 |
+
// Create optimized temporary canvas
|
| 215 |
+
const tempCanvas = document.createElement("canvas");
|
| 216 |
+
const width = Math.max(bounds.maxX - bounds.minX, canvas.width);
|
| 217 |
+
const height = Math.max(bounds.maxY - bounds.minY, canvas.height);
|
| 218 |
+
tempCanvas.width = width * scale;
|
| 219 |
+
tempCanvas.height = height * scale;
|
| 220 |
+
|
| 221 |
+
// Use OffscreenCanvas when available for better performance
|
| 222 |
+
const ctx = tempCanvas.getContext("2d", { alpha: true });
|
| 223 |
+
const roughCanvas = rough.canvas(tempCanvas);
|
| 224 |
+
|
| 225 |
+
// Draw elements with transformation
|
| 226 |
+
ctx.save();
|
| 227 |
+
ctx.translate(-bounds.minX * scale, -bounds.minY * scale);
|
| 228 |
+
ctx.scale(scale, scale);
|
| 229 |
+
elements.forEach(element => drawElement(roughCanvas, ctx, element));
|
| 230 |
+
ctx.restore();
|
| 231 |
+
|
| 232 |
+
// Process image data
|
| 233 |
+
const imageData = ctx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
| 234 |
+
const visited = new Set();
|
| 235 |
+
const initialRegions = [];
|
| 236 |
+
|
| 237 |
+
// Optimize pixel scanning with stride
|
| 238 |
+
const stride = 4; // Check every 4th pixel initially
|
| 239 |
+
for (let y = 0; y < tempCanvas.height; y += stride) {
|
| 240 |
+
for (let x = 0; x < tempCanvas.width; x += stride) {
|
| 241 |
+
const key = `${x},${y}`;
|
| 242 |
+
if (visited.has(key)) continue;
|
| 243 |
+
|
| 244 |
+
if (!getPixel(imageData, x, y)) continue;
|
| 245 |
+
|
| 246 |
+
const region = floodFill(imageData, x, y, visited);
|
| 247 |
+
if (region.points.length >= minRegionSize) {
|
| 248 |
+
// Transform coordinates back
|
| 249 |
+
const transformedRegion = {
|
| 250 |
+
...region,
|
| 251 |
+
minX: region.minX / scale + bounds.minX,
|
| 252 |
+
maxX: region.maxX / scale + bounds.minX,
|
| 253 |
+
minY: region.minY / scale + bounds.minY,
|
| 254 |
+
maxY: region.maxY / scale + bounds.minY,
|
| 255 |
+
points: region.points.map(point => ({
|
| 256 |
+
x: point.x / scale + bounds.minX,
|
| 257 |
+
y: point.y / scale + bounds.minY
|
| 258 |
+
}))
|
| 259 |
+
};
|
| 260 |
+
initialRegions.push(transformedRegion);
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
// Optimize region merging
|
| 266 |
+
const mergedRegions = [];
|
| 267 |
+
const used = new Set();
|
| 268 |
+
|
| 269 |
+
for (let i = 0; i < initialRegions.length; i++) {
|
| 270 |
+
if (used.has(i)) continue;
|
| 271 |
+
|
| 272 |
+
let currentRegion = initialRegions[i];
|
| 273 |
+
used.add(i);
|
| 274 |
+
|
| 275 |
+
let merged;
|
| 276 |
+
do {
|
| 277 |
+
merged = false;
|
| 278 |
+
for (let j = 0; j < initialRegions.length; j++) {
|
| 279 |
+
if (used.has(j)) continue;
|
| 280 |
+
|
| 281 |
+
const distance = calculateRegionDistance(currentRegion, initialRegions[j]);
|
| 282 |
+
if (distance <= groupingDistance) {
|
| 283 |
+
currentRegion = mergeRegions(currentRegion, initialRegions[j]);
|
| 284 |
+
used.add(j);
|
| 285 |
+
merged = true;
|
| 286 |
+
}
|
| 287 |
+
}
|
| 288 |
+
} while (merged);
|
| 289 |
+
|
| 290 |
+
mergedRegions.push(currentRegion);
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
// Return final regions
|
| 294 |
+
return mergedRegions.map((region, index) => ({
|
| 295 |
+
id: index + 1,
|
| 296 |
+
bounds: {
|
| 297 |
+
x1: region.minX,
|
| 298 |
+
y1: region.minY,
|
| 299 |
+
x2: region.maxX,
|
| 300 |
+
y2: region.maxY
|
| 301 |
+
},
|
| 302 |
+
elements: []
|
| 303 |
+
}));
|
| 304 |
+
};
|
| 305 |
+
|
| 306 |
+
const useHistory = initialState => {
|
| 307 |
+
const [index, setIndex] = useState(0);
|
| 308 |
+
const [history, setHistory] = useState([initialState]);
|
| 309 |
+
|
| 310 |
+
const setState = (action, overwrite = false) => {
|
| 311 |
+
const newState = typeof action === "function" ? action(history[index]) : action;
|
| 312 |
+
if (overwrite) {
|
| 313 |
+
const historyCopy = [...history];
|
| 314 |
+
historyCopy[index] = newState;
|
| 315 |
+
setHistory(historyCopy);
|
| 316 |
+
} else {
|
| 317 |
+
const updatedState = [...history].slice(0, index + 1);
|
| 318 |
+
setHistory([...updatedState, newState]);
|
| 319 |
+
setIndex(prevState => prevState + 1);
|
| 320 |
+
}
|
| 321 |
+
};
|
| 322 |
+
|
| 323 |
+
const undo = () => index > 0 && setIndex(prevState => prevState - 1);
|
| 324 |
+
const redo = () => index < history.length - 1 && setIndex(prevState => prevState + 1);
|
| 325 |
+
|
| 326 |
+
return [history[index], setState, undo, redo];
|
| 327 |
+
};
|
| 328 |
+
|
| 329 |
+
const usePressedKeys = () => {
|
| 330 |
+
const [pressedKeys, setPressedKeys] = useState(new Set());
|
| 331 |
+
|
| 332 |
+
useEffect(() => {
|
| 333 |
+
const handleKeyDown = event => {
|
| 334 |
+
setPressedKeys(prevKeys => new Set(prevKeys).add(event.key));
|
| 335 |
+
};
|
| 336 |
+
|
| 337 |
+
const handleKeyUp = event => {
|
| 338 |
+
setPressedKeys(prevKeys => {
|
| 339 |
+
const updatedKeys = new Set(prevKeys);
|
| 340 |
+
updatedKeys.delete(event.key);
|
| 341 |
+
return updatedKeys;
|
| 342 |
+
});
|
| 343 |
+
};
|
| 344 |
+
|
| 345 |
+
window.addEventListener("keydown", handleKeyDown);
|
| 346 |
+
window.addEventListener("keyup", handleKeyUp);
|
| 347 |
+
return () => {
|
| 348 |
+
window.removeEventListener("keydown", handleKeyDown);
|
| 349 |
+
window.removeEventListener("keyup", handleKeyUp);
|
| 350 |
+
};
|
| 351 |
+
}, []);
|
| 352 |
+
|
| 353 |
+
return pressedKeys;
|
| 354 |
+
};
|
| 355 |
+
const drawBoundingBoxes = (context, regions, scale) => {
|
| 356 |
+
context.save();
|
| 357 |
+
regions.forEach(region => {
|
| 358 |
+
const { bounds, id } = region;
|
| 359 |
+
|
| 360 |
+
// Set styling for bounding box
|
| 361 |
+
context.strokeStyle = "#FF5733"; // Custom color for better visibility
|
| 362 |
+
context.lineWidth = 1 / scale;
|
| 363 |
+
context.setLineDash([5 / scale, 5 / scale]); // Dotted lines for bounding box
|
| 364 |
+
|
| 365 |
+
// Draw bounding box
|
| 366 |
+
context.strokeRect(
|
| 367 |
+
bounds.x1,
|
| 368 |
+
bounds.y1,
|
| 369 |
+
bounds.x2 - bounds.x1,
|
| 370 |
+
bounds.y2 - bounds.y1
|
| 371 |
+
);
|
| 372 |
+
|
| 373 |
+
// Display region ID inside bounding box
|
| 374 |
+
context.setLineDash([]); // Reset line dash for text
|
| 375 |
+
context.font = `${16 / scale}px sans-serif`;
|
| 376 |
+
context.fillStyle = "#FF5733";
|
| 377 |
+
context.fillText(
|
| 378 |
+
`#${id}`,
|
| 379 |
+
bounds.x1 + 5 / scale,
|
| 380 |
+
bounds.y1 + 20 / scale
|
| 381 |
+
);
|
| 382 |
+
});
|
| 383 |
+
context.restore();
|
| 384 |
+
};
|
| 385 |
+
|
| 386 |
+
const App = () => {
|
| 387 |
+
const [elements, setElements, undo, redo] = useHistory([]);
|
| 388 |
+
const [action, setAction] = useState("none");
|
| 389 |
+
const [tool, setTool] = useState("rectangle");
|
| 390 |
+
const [selectedElement, setSelectedElement] = useState(null);
|
| 391 |
+
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
|
| 392 |
+
const [startPanMousePosition, setStartPanMousePosition] = useState({ x: 0, y: 0 });
|
| 393 |
+
const [scale, setScale] = useState(1);
|
| 394 |
+
const [scaleOffset, setScaleOffset] = useState({ x: 0, y: 0 });
|
| 395 |
+
const [captureArea, setCaptureArea] = useState(null);
|
| 396 |
+
const [drawingRegions, setDrawingRegions] = useState([]);
|
| 397 |
+
const textAreaRef = useRef();
|
| 398 |
+
const pressedKeys = usePressedKeys();
|
| 399 |
+
const [pencilSize, setPencilSize] = useState(3);
|
| 400 |
+
const [isDrawing, setIsDrawing] = useState(false);
|
| 401 |
+
const handleDetectRegions = () => {
|
| 402 |
+
const canvas = document.getElementById("canvas");
|
| 403 |
+
if (!canvas) return;
|
| 404 |
+
|
| 405 |
+
const newRegions = detectRegions(canvas, elements, panOffset, scale, scaleOffset);
|
| 406 |
+
setDrawingRegions(newRegions);
|
| 407 |
+
|
| 408 |
+
// Notify user
|
| 409 |
+
if (newRegions.length > 0) {
|
| 410 |
+
console.log(`Phát hiện ${newRegions.length} vùng vẽ!`);
|
| 411 |
+
alert(`Đã phát hiện ${newRegions.length} vùng vẽ!`);
|
| 412 |
+
} else {
|
| 413 |
+
alert("Không phát hiện được vùng vẽ nào!");
|
| 414 |
+
}
|
| 415 |
+
};
|
| 416 |
+
useLayoutEffect(() => {
|
| 417 |
+
const canvas = document.getElementById("canvas");
|
| 418 |
+
const context = canvas.getContext("2d");
|
| 419 |
+
const roughCanvas = rough.canvas(canvas);
|
| 420 |
+
|
| 421 |
+
// Clear canvas for redrawing
|
| 422 |
+
context.clearRect(0, 0, canvas.width, canvas.height);
|
| 423 |
+
|
| 424 |
+
// Calculate scaled dimensions
|
| 425 |
+
const scaledWidth = canvas.width * scale;
|
| 426 |
+
const scaledHeight = canvas.height * scale;
|
| 427 |
+
const scaleOffsetX = (scaledWidth - canvas.width) / 2;
|
| 428 |
+
const scaleOffsetY = (scaledHeight - canvas.height) / 2;
|
| 429 |
+
setScaleOffset({ x: scaleOffsetX, y: scaleOffsetY });
|
| 430 |
+
|
| 431 |
+
context.save();
|
| 432 |
+
context.translate(panOffset.x * scale - scaleOffsetX, panOffset.y * scale - scaleOffsetY);
|
| 433 |
+
context.scale(scale, scale);
|
| 434 |
+
|
| 435 |
+
// Draw elements
|
| 436 |
+
elements.forEach(element => {
|
| 437 |
+
if (action === "writing" && selectedElement?.id === element.id) return;
|
| 438 |
+
drawElement(roughCanvas, context, element);
|
| 439 |
+
});
|
| 440 |
+
|
| 441 |
+
// Call drawBoundingBoxes to render detected regions
|
| 442 |
+
drawBoundingBoxes(context, drawingRegions, scale);
|
| 443 |
+
|
| 444 |
+
context.restore();
|
| 445 |
+
}, [elements, action, selectedElement, panOffset, scale, drawingRegions]);
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
useEffect(() => {
|
| 449 |
+
const undoRedoFunction = event => {
|
| 450 |
+
if ((event.metaKey || event.ctrlKey) && event.key === "z") {
|
| 451 |
+
if (event.shiftKey) {
|
| 452 |
+
redo();
|
| 453 |
+
} else {
|
| 454 |
+
undo();
|
| 455 |
+
}
|
| 456 |
+
}
|
| 457 |
+
};
|
| 458 |
+
|
| 459 |
+
document.addEventListener("keydown", undoRedoFunction);
|
| 460 |
+
return () => {
|
| 461 |
+
document.removeEventListener("keydown", undoRedoFunction);
|
| 462 |
+
};
|
| 463 |
+
}, [undo, redo]);
|
| 464 |
+
useEffect(() => {
|
| 465 |
+
const panOrZoomFunction = event => {
|
| 466 |
+
if (pressedKeys.has("Meta") || pressedKeys.has("Control")) {
|
| 467 |
+
onZoom(event.deltaY * -0.01);
|
| 468 |
+
} else {
|
| 469 |
+
setPanOffset(prevState => ({
|
| 470 |
+
x: prevState.x - event.deltaX,
|
| 471 |
+
y: prevState.y - event.deltaY,
|
| 472 |
+
}));
|
| 473 |
+
}
|
| 474 |
+
};
|
| 475 |
+
|
| 476 |
+
document.addEventListener("wheel", panOrZoomFunction);
|
| 477 |
+
return () => {
|
| 478 |
+
document.removeEventListener("wheel", panOrZoomFunction);
|
| 479 |
+
};
|
| 480 |
+
}, [pressedKeys]);
|
| 481 |
+
const onZoom = delta => {
|
| 482 |
+
setScale(prevState => Math.min(Math.max(prevState + delta, 0.1), 2));
|
| 483 |
+
};
|
| 484 |
+
const handleDownloadRegions = () => {
|
| 485 |
+
const canvas = document.getElementById("canvas");
|
| 486 |
+
if (!canvas || drawingRegions.length === 0) {
|
| 487 |
+
alert("Không có vùng vẽ nào để tải xuống!");
|
| 488 |
+
return;
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
downloadAllRegions(canvas, drawingRegions, elements, scale, panOffset, scaleOffset);
|
| 492 |
+
alert(`Đã tải xuống ${drawingRegions.length} vùng vẽ!`);
|
| 493 |
+
};
|
| 494 |
+
const {
|
| 495 |
+
handleMouseDown,
|
| 496 |
+
handleMouseMove,
|
| 497 |
+
handleMouseUp,
|
| 498 |
+
handleBlur
|
| 499 |
+
} = createMouseHandlers({
|
| 500 |
+
action,
|
| 501 |
+
setAction,
|
| 502 |
+
tool,
|
| 503 |
+
setTool,
|
| 504 |
+
elements,
|
| 505 |
+
setElements,
|
| 506 |
+
selectedElement,
|
| 507 |
+
setSelectedElement,
|
| 508 |
+
panOffset,
|
| 509 |
+
setPanOffset,
|
| 510 |
+
scale,
|
| 511 |
+
scaleOffset,
|
| 512 |
+
startPanMousePosition,
|
| 513 |
+
setStartPanMousePosition,
|
| 514 |
+
pressedKeys,
|
| 515 |
+
pencilSize,
|
| 516 |
+
setCaptureArea,
|
| 517 |
+
isDrawing,
|
| 518 |
+
setIsDrawing
|
| 519 |
+
});
|
| 520 |
+
|
| 521 |
+
return (
|
| 522 |
+
|
| 523 |
+
<DrawingBoard
|
| 524 |
+
tool={tool}
|
| 525 |
+
setTool={setTool}
|
| 526 |
+
elements={elements}
|
| 527 |
+
pencilSize={pencilSize}
|
| 528 |
+
setPencilSize={setPencilSize}
|
| 529 |
+
scale={scale}
|
| 530 |
+
setScale={setScale}
|
| 531 |
+
onZoom={onZoom}
|
| 532 |
+
undo={undo}
|
| 533 |
+
redo={redo}
|
| 534 |
+
action={action}
|
| 535 |
+
selectedElement={selectedElement}
|
| 536 |
+
panOffset={panOffset}
|
| 537 |
+
scaleOffset={scaleOffset}
|
| 538 |
+
handleMouseDown={handleMouseDown}
|
| 539 |
+
handleMouseMove={handleMouseMove}
|
| 540 |
+
handleMouseUp={handleMouseUp}
|
| 541 |
+
handleBlur={handleBlur}
|
| 542 |
+
handleDetectRegions={handleDetectRegions}
|
| 543 |
+
handleDownloadRegions={handleDownloadRegions}
|
| 544 |
+
/>
|
| 545 |
+
);
|
| 546 |
+
};
|
| 547 |
+
|
| 548 |
+
export default App;
|