qtitpc commited on
Commit
91c8a81
·
verified ·
1 Parent(s): 672f3f1

Upload 23 files

Browse files
.eslintrc.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {
2
+ "extends": "next/core-web-vitals"
3
+ }
.gitignore ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ /coverage
15
+
16
+ # next.js
17
+ /.next/
18
+ /out/
19
+
20
+ # production
21
+ /build
22
+
23
+ # misc
24
+ .DS_Store
25
+ *.pem
26
+
27
+ # debug
28
+ npm-debug.log*
29
+ yarn-debug.log*
30
+ yarn-error.log*
31
+
32
+ # env files (can opt-in for commiting if needed)
33
+ .env*
34
+
35
+ # vercel
36
+ .vercel
37
+
38
+ # typescript
39
+ *.tsbuildinfo
40
+ next-env.d.ts
README.md CHANGED
@@ -1,12 +1,36 @@
1
- ---
2
- title: Drawing Model
3
- emoji: 🦀
4
- colorFrom: pink
5
- colorTo: purple
6
- sdk: static
7
- pinned: false
8
- license: other
9
- short_description: idk
10
- ---
11
-
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2
+
3
+ ## Getting Started
4
+
5
+ First, run the development server:
6
+
7
+ ```bash
8
+ npm run dev
9
+ # or
10
+ yarn dev
11
+ # or
12
+ pnpm dev
13
+ # or
14
+ bun dev
15
+ ```
16
+
17
+ Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18
+
19
+ You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file.
20
+
21
+ This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22
+
23
+ ## Learn More
24
+
25
+ To learn more about Next.js, take a look at the following resources:
26
+
27
+ - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28
+ - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29
+
30
+ You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31
+
32
+ ## Deploy on Vercel
33
+
34
+ The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35
+
36
+ Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
jsconfig.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "paths": {
4
+ "@/*": ["./src/*"]
5
+ }
6
+ }
7
+ }
next.config.mjs ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {};
3
+
4
+ export default nextConfig;
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "drawing-app-qt-minh",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev --turbopack",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "next lint"
10
+ },
11
+ "dependencies": {
12
+ "lucide": "^0.453.0",
13
+ "lucide-react": "^0.453.0",
14
+ "next": "15.0.1",
15
+ "perfect-freehand": "^1.2.2",
16
+ "react": "19.0.0-rc-69d4b800-20241021",
17
+ "react-dom": "19.0.0-rc-69d4b800-20241021",
18
+ "roughjs": "^4.6.6"
19
+ },
20
+ "devDependencies": {
21
+ "eslint": "^8",
22
+ "eslint-config-next": "15.0.1",
23
+ "postcss": "^8",
24
+ "tailwindcss": "^3.4.1"
25
+ }
26
+ }
postcss.config.mjs ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('postcss-load-config').Config} */
2
+ const config = {
3
+ plugins: {
4
+ tailwindcss: {},
5
+ },
6
+ };
7
+
8
+ export default config;
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,627 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ const safeLocalStorage = {
32
+ getItem: (key) => {
33
+ try {
34
+ if (typeof window !== 'undefined') {
35
+ return window.localStorage.getItem(key);
36
+ }
37
+ return null;
38
+ } catch (e) {
39
+ console.warn('localStorage is not available:', e);
40
+ return null;
41
+ }
42
+ },
43
+ setItem: (key, value) => {
44
+ try {
45
+ if (typeof window !== 'undefined') {
46
+ window.localStorage.setItem(key, value);
47
+ }
48
+ } catch (e) {
49
+ console.warn('localStorage is not available:', e);
50
+ }
51
+ }
52
+ };
53
+
54
+ const getInitialDarkMode = () => {
55
+ if (typeof window === 'undefined') return false;
56
+
57
+ const savedMode = safeLocalStorage.getItem('drawingBoardDarkMode');
58
+ if (savedMode !== null) {
59
+ return savedMode === 'true';
60
+ }
61
+ return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
62
+ };
63
+
64
+ const DrawingBoard = ({
65
+ tool,
66
+ setTool,
67
+ elements,
68
+ pencilSize,
69
+ setPencilSize,
70
+ scale,
71
+ setScale,
72
+ onZoom,
73
+ undo,
74
+ redo,
75
+ action,
76
+ selectedElement,
77
+ panOffset,
78
+ scaleOffset,
79
+ handleMouseDown,
80
+ handleMouseMove,
81
+ handleMouseUp,
82
+ handleBlur,
83
+ captureDrawnArea,
84
+ handleDetectRegions,
85
+ handleDownloadRegions
86
+ }) => {
87
+ const textAreaRef = React.useRef(null);
88
+ const backgroundCanvasRef = React.useRef(null);
89
+ const drawingCanvasRef = React.useRef(null);
90
+ const [backgroundType, setBackgroundType] = React.useState('none');
91
+ const [gridSize, setGridSize] = React.useState(45);
92
+ const [isMenuOpen, setIsMenuOpen] = React.useState(false);
93
+ const [showDownloadButton, setShowDownloadButton] = React.useState(false);
94
+ const [showColorPicker, setShowColorPicker] = React.useState(false);
95
+ const [currentColor, setCurrentColor] = React.useState('#000000');
96
+ const [customColor, setCustomColor] = React.useState('#000000');
97
+ const [darkMode, setDarkMode] = React.useState(getInitialDarkMode);
98
+
99
+ React.useEffect(() => {
100
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
101
+ const handleChange = (e) => {
102
+ const savedMode = localStorage.getItem('drawingBoardDarkMode');
103
+ if (savedMode === null) {
104
+ setDarkMode(e.matches);
105
+ }
106
+ };
107
+
108
+ mediaQuery.addEventListener('change', handleChange);
109
+ return () => mediaQuery.removeEventListener('change', handleChange);
110
+ }, []);
111
+ React.useEffect(() => {
112
+ safeLocalStorage.setItem('drawingBoardDarkMode', darkMode);
113
+ drawElements();
114
+ }, [darkMode]);
115
+ React.useEffect(() => {
116
+ localStorage.setItem('drawingBoardDarkMode', darkMode);
117
+ drawElements();
118
+ }, [darkMode]);
119
+
120
+ const drawBackground = React.useCallback(() => {
121
+ const canvas = backgroundCanvasRef.current;
122
+ if (!canvas) return;
123
+
124
+ const ctx = canvas.getContext('2d');
125
+ const width = canvas.width;
126
+ const height = canvas.height;
127
+
128
+ ctx.fillStyle = darkMode ? '#ffffff' : '#000000';
129
+ ctx.fillRect(0, 0, width, height);
130
+
131
+ const offsetX = (panOffset.x * scale - scaleOffset.x) % (gridSize * scale);
132
+ const offsetY = (panOffset.y * scale - scaleOffset.y) % (gridSize * scale);
133
+ const gridColor = darkMode ? 'rgba(0, 0, 0, 0.6)' : 'rgba(255, 255, 255, 0.6)';
134
+
135
+ if (backgroundType === 'grid') {
136
+ const majorGridSize = gridSize *5;
137
+ const majorGridColor = darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
138
+ const minorGridColor = darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
139
+ ctx.beginPath();
140
+ ctx.strokeStyle = gridColor;
141
+ ctx.lineWidth = 0.5;
142
+
143
+ for (let x = offsetX; x < width; x += gridSize * scale) {
144
+ ctx.moveTo(x, 0);
145
+ ctx.lineTo(x, height);
146
+ }
147
+
148
+ for (let y = offsetY; y < height; y += gridSize * scale) {
149
+ ctx.moveTo(0, y);
150
+ ctx.lineTo(width, y);
151
+ }
152
+
153
+ ctx.stroke();
154
+ ctx.beginPath();
155
+ ctx.strokeStyle = majorGridColor;
156
+ ctx.lineWidth = 1.5;
157
+
158
+ // Tính offset cho grid lớn
159
+ const majorOffsetX = offsetX - (offsetX % (majorGridSize * scale));
160
+ const majorOffsetY = offsetY - (offsetY % (majorGridSize * scale));
161
+ for (let x = majorOffsetX; x < width; x += majorGridSize * scale) {
162
+ ctx.moveTo(x, 0);
163
+ ctx.lineTo(x, height);
164
+ }
165
+
166
+ // Vẽ các đường ngang lớn
167
+ for (let y = majorOffsetY; y < height; y += majorGridSize * scale) {
168
+ ctx.moveTo(0, y);
169
+ ctx.lineTo(width, y);
170
+ }
171
+ ctx.stroke();
172
+ } else if (backgroundType === 'dots') {
173
+ ctx.fillStyle = gridColor;
174
+ const dotSize = 2;
175
+
176
+ for (let x = offsetX; x < width; x += gridSize * scale) {
177
+ for (let y = offsetY; y < height; y += gridSize * scale) {
178
+ ctx.beginPath();
179
+ ctx.arc(x, y, 1, 0, Math.PI * 2);
180
+ ctx.fill();
181
+ }
182
+ }
183
+ }
184
+ }, [backgroundType, gridSize, scale, panOffset, scaleOffset, darkMode]);
185
+ const toggleBackground = () => {
186
+ setBackgroundType(prev => {
187
+ if (prev === 'none') return 'grid';
188
+ if (prev === 'grid') return 'dots';
189
+ return 'none';
190
+ });
191
+ };
192
+ const drawElements = React.useCallback(() => {
193
+ const canvas = drawingCanvasRef.current;
194
+ if (!canvas) return;
195
+
196
+ const ctx = canvas.getContext('2d');
197
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
198
+
199
+ ctx.save();
200
+ ctx.translate(panOffset.x * scale - scaleOffset.x, panOffset.y * scale - scaleOffset.y);
201
+ ctx.scale(scale, scale);
202
+
203
+ elements.forEach(element => {
204
+ if (element.type === 'pencil') {
205
+ ctx.beginPath();
206
+ ctx.strokeStyle = element.color || currentColor;
207
+ ctx.lineWidth = element.size;
208
+ ctx.lineCap = 'round';
209
+ ctx.lineJoin = 'round';
210
+
211
+ element.points.forEach((point, index) => {
212
+ if (index === 0) {
213
+ ctx.moveTo(point.x, point.y);
214
+ } else {
215
+ ctx.lineTo(point.x, point.y);
216
+ }
217
+ });
218
+ ctx.stroke();
219
+ } else if (element.type === 'line') {
220
+ ctx.beginPath();
221
+ ctx.strokeStyle = element.color || currentColor;
222
+ ctx.lineWidth = 2;
223
+ ctx.moveTo(element.x1, element.y1);
224
+ ctx.lineTo(element.x2, element.y2);
225
+ ctx.stroke();
226
+ } else if (element.type === 'rectangle') {
227
+ ctx.strokeStyle = element.color || currentColor;
228
+ ctx.lineWidth = 2;
229
+ ctx.strokeRect(
230
+ element.x1,
231
+ element.y1,
232
+ element.x2 - element.x1,
233
+ element.y2 - element.y1
234
+ );
235
+ } else if (element.type === 'text') {
236
+ ctx.font = '24px sans-serif';
237
+ ctx.fillStyle = element.color || currentColor;
238
+ ctx.fillText(element.text, element.x1, element.y1);
239
+ }
240
+ });
241
+
242
+ ctx.restore();
243
+ }, [elements, scale, panOffset, scaleOffset, currentColor]);
244
+ React.useEffect(() => {
245
+ drawBackground();
246
+ drawElements();
247
+ }, [drawBackground, drawElements]);
248
+ const ColorPickerButton = () => (
249
+ <div className="relative">
250
+ <button
251
+ onClick={() => setShowColorPicker(!showColorPicker)}
252
+ className="p-2 rounded hover:bg-opacity-80 flex items-center gap-1"
253
+ title="Color Picker"
254
+ >
255
+ <div
256
+ className="w-5 h-5 rounded-full border border-gray-300"
257
+ style={{ backgroundColor: currentColor }}
258
+ />
259
+ <Palette size={16} color={iconColor} />
260
+ </button>
261
+ {showColorPicker && <ColorPicker />}
262
+ </div>
263
+ );
264
+
265
+ React.useEffect(() => {
266
+ const handleResize = () => {
267
+ if (backgroundCanvasRef.current && drawingCanvasRef.current) {
268
+ backgroundCanvasRef.current.width = window.innerWidth;
269
+ backgroundCanvasRef.current.height = window.innerHeight;
270
+ drawingCanvasRef.current.width = window.innerWidth;
271
+ drawingCanvasRef.current.height = window.innerHeight;
272
+ drawBackground();
273
+ drawElements();
274
+ }
275
+ };
276
+
277
+ window.addEventListener('resize', handleResize);
278
+ handleResize();
279
+ return () => window.removeEventListener('resize', handleResize);
280
+ }, [drawBackground, drawElements]);
281
+
282
+ const toggleDarkMode = () => {
283
+ setDarkMode(prev => !prev);
284
+ };
285
+
286
+ const handleDetectAndShowDownload = () => {
287
+ handleDetectRegions();
288
+ setShowDownloadButton(true);
289
+ setIsMenuOpen(false);
290
+ };
291
+
292
+ const iconColor = darkMode ? "#000000" : "#ffffff"; // Đảo ngược màu icon
293
+ const barBgColor = darkMode ? "bg-white" : "bg-gray-800"; // Giữ nguyên màu nền
294
+ const textColor = darkMode ? 'text-black' : 'text-white'; // Đảo ngược màu text
295
+ const handleColorChange =(color)=>{
296
+ setCurrentColor(color);
297
+ setShowColorPicker(false);
298
+ };
299
+ const handleCustomColorChange = (e) =>{
300
+ const newColor = e.target.value;
301
+ setCustomColor(newColor);
302
+ setCurrentColor(newColor);
303
+ };
304
+ const ColorPicker = () => (
305
+ <div className={`absolute top-full mt-2 p-2 ${barBgColor} rounded-lg shadow-lg`}>
306
+ <div className="grid grid-cols-4 gap-2 mb-2">
307
+ {PRESET_COLORS.map((color, index) => (
308
+ <button
309
+ key={index}
310
+ onClick={() => handleColorChange(color)}
311
+ className="w-6 h-6 rounded-full border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
312
+ style={{
313
+ backgroundColor: color,
314
+ border: currentColor === color ? '2px solid #3B82F6' : '1px solid #D1D5DB'
315
+ }}
316
+ />
317
+ ))}
318
+ </div>
319
+ <div className="flex items-center gap-2 mt-2 pt-2 border-t border-gray-600">
320
+ <input
321
+ type="color"
322
+ value={customColor}
323
+ onChange={handleCustomColorChange}
324
+ className="w-8 h-8 p-0 border-0 rounded cursor-pointer"
325
+ />
326
+ <input
327
+ type="text"
328
+ value={customColor.toUpperCase()}
329
+ onChange={(e) => {
330
+ const val = e.target.value;
331
+ if (/^#[0-9A-F]{0,6}$/i.test(val)) {
332
+ setCustomColor(val);
333
+ if (val.length === 7) setCurrentColor(val);
334
+ }
335
+ }}
336
+ className={`w-20 px-1 py-0.5 text-sm rounded ${
337
+ darkMode ? 'bg-gray-700 text-white' : 'bg-white text-black'
338
+ }`}
339
+ placeholder="#000000"
340
+ />
341
+ </div>
342
+ </div>
343
+ );
344
+ return (
345
+ <div className={`relative w-full h-screen ${darkMode ? 'bg-gray-900' : 'bg-white'}`}>
346
+ {/* Corner Menu Button */}
347
+ <button
348
+ onClick={() => setIsMenuOpen(!isMenuOpen)}
349
+ className={`fixed top-4 left-4 z-20 p-2 ${barBgColor} rounded-lg shadow-lg hover:bg-opacity-80`}
350
+ >
351
+ {isMenuOpen ?
352
+ <X size={24} color={iconColor} /> :
353
+ <Menu size={24} color={iconColor} />
354
+ }
355
+ </button>
356
+
357
+ {/* Corner Menu Panel */}
358
+ {isMenuOpen && (
359
+ <div className={`fixed top-16 left-4 z-20 ${barBgColor} rounded-lg shadow-lg p-4 w-64`}>
360
+ <div className="flex flex-col gap-4">
361
+ {/* Theme Toggle */}
362
+ <button
363
+ onClick={toggleDarkMode}
364
+ className={`w-full flex items-center justify-between p-2 rounded hover:bg-opacity-80 ${darkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-100'}`}
365
+ >
366
+ <span className={`text-sm ${darkMode ? 'text-gray-600' : 'text-white'}`}>
367
+ {darkMode ? 'Light Mode' : 'Dark Mode'}
368
+ </span>
369
+ {darkMode ?
370
+ <Sun size={20} color={iconColor} /> :
371
+ <Moon size={20} color={iconColor} />
372
+ }
373
+ </button>
374
+
375
+ {/* Grid Toggle */}
376
+ <button
377
+ onClick={() => setBackgroundType(prev => prev === 'grid' ? 'none' : 'grid')}
378
+ className={`w-full flex items-center justify-between p-2 rounded hover:bg-opacity-80 ${darkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-100'}`}
379
+ >
380
+ <span className={`text-sm ${darkMode ? 'text-gray-600' : 'text-white'}`}>
381
+ Show Grid
382
+ </span>
383
+ <Grid
384
+ size={20}
385
+ color={iconColor}
386
+ className={backgroundType === 'grid' ? 'opacity-100' : 'opacity-50'}
387
+ />
388
+ </button>
389
+ <button
390
+ onClick={toggleBackground}
391
+ className={`w-full flex items-center justify-between p-2 rounded hover:bg-opacity-80 ${
392
+ darkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-100'
393
+ }`}
394
+ >
395
+ <span className={`text-sm ${darkMode ? 'text-black' : 'text-white'}`}>
396
+ {backgroundType === 'none' ? 'No Grid' :
397
+ backgroundType === 'grid' ? 'Grid Lines' : 'Grid Dots'}
398
+ </span>
399
+ <Grid
400
+ size={20}
401
+ color={iconColor}
402
+ className={backgroundType !== 'none' ? 'opacity-100' : 'opacity-50'}
403
+ />
404
+ </button>
405
+
406
+ {backgroundType !== 'none' && (
407
+ <div className="flex flex-col gap-2 p-2">
408
+ <div className="flex items-center justify-between">
409
+ <span className={`text-sm ${darkMode ? 'text-black' : 'text-white'}`}>
410
+ Grid Size
411
+ </span>
412
+ <span className={`text-sm ${darkMode ? 'text-black' : 'text-white'}`}>
413
+ {gridSize}px
414
+ </span>
415
+ </div>
416
+ <input
417
+ type="range"
418
+ min="10"
419
+ max="100"
420
+ value={gridSize}
421
+ onChange={(e) => setGridSize(parseInt(e.target.value))}
422
+ className="w-full h-1 bg-gray-300 rounded-lg appearance-none cursor-pointer"
423
+ />
424
+ </div>
425
+ )}
426
+ <style jsx global>{`
427
+ input[type="range"] {
428
+ -webkit-appearance: none;
429
+ height: 4px;
430
+ background: ${darkMode ? '#ffffff40' : '#00000040'};
431
+ border-radius: 2px;
432
+ outline: none;
433
+ }
434
+
435
+ input[type="range"]::-webkit-slider-thumb {
436
+ -webkit-appearance: none;
437
+ appearance: none;
438
+ width: 12px;
439
+ height: 12px;
440
+ background: ${darkMode ? '#ffffff' : '#000000'};
441
+ border-radius: 50%;
442
+ cursor: pointer;
443
+ }
444
+
445
+ input[type="range"]::-moz-range-thumb {
446
+ width: 12px;
447
+ height: 12px;
448
+ background: ${darkMode ? '#ffffff' : '#000000'};
449
+ border-radius: 50%;
450
+ cursor: pointer;
451
+ border: none;
452
+ }
453
+
454
+ input[type="range"]:hover::-webkit-slider-thumb {
455
+ background: #3b82f6;
456
+ }
457
+
458
+ input[type="range"]:hover::-moz-range-thumb {
459
+ background: #3b82f6;
460
+ }
461
+ `}</style>
462
+ {/* Region Detection */}
463
+ <button
464
+ onClick={handleDetectAndShowDownload}
465
+ className={`w-full flex items-center justify-between p-2 rounded hover:bg-opacity-80 ${darkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-100'}`}
466
+ >
467
+ <span className={`text-sm ${darkMode ? 'text-gray-600' : 'text-white'}`}>
468
+ Detect Regions
469
+ </span>
470
+ <Scan size={20} color={iconColor} />
471
+ </button>
472
+ </div>
473
+ </div>
474
+ )}
475
+
476
+ {/* Main Toolbar */}
477
+ <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`}>
478
+ <div className="flex items-center gap-1 pr-2 border-r border-gray-200">
479
+ <button onClick={undo} className="p-2 rounded hover:bg-opacity-80" title="Undo">
480
+ <Undo2 size={20} color={iconColor} />
481
+ </button>
482
+ <button onClick={redo} className="p-2 rounded hover:bg-opacity-80" title="Redo">
483
+ <Redo2 size={20} color={iconColor} />
484
+ </button>
485
+ </div>
486
+
487
+ <div className="flex items-center gap-1 px-2">
488
+ <ColorPickerButton />
489
+ <button
490
+ onClick={() => setTool('selection')}
491
+ className={`p-2 rounded ${tool === 'selection' ? 'bg-blue-500' : 'hover:bg-opacity-80'}`}
492
+ title="Selection"
493
+ >
494
+ <Hand size={20} color={tool === 'selection' ? '#ffffff' : iconColor} />
495
+ </button>
496
+ <button
497
+ onClick={() => setTool('line')}
498
+ className={`p-2 rounded ${tool === 'line' ? 'bg-blue-500' : 'hover:bg-opacity-80'}`}
499
+ title="Line"
500
+ >
501
+ <GripHorizontal size={20} color={tool === 'line' ? '#ffffff' : iconColor} />
502
+ </button>
503
+ <button
504
+ onClick={() => setTool('rectangle')}
505
+ className={`p-2 rounded ${tool === 'rectangle' ? 'bg-blue-500' : 'hover:bg-opacity-80'}`}
506
+ title="Rectangle"
507
+ >
508
+ <Square size={20} color={tool === 'rectangle' ? '#ffffff' : iconColor} />
509
+ </button>
510
+ <button
511
+ onClick={() => setTool('pencil')}
512
+ className={`p-2 rounded ${tool === 'pencil' ? 'bg-blue-500' : 'hover:bg-opacity-80'}`}
513
+ title="Pencil"
514
+ >
515
+ <Pencil size={20} color={tool === 'pencil' ? '#ffffff' : iconColor} />
516
+ </button>
517
+ <button
518
+ onClick={() => setTool('eraser')}
519
+ className={`p-2 rounded ${tool === 'eraser' ? 'bg-blue-500' : 'hover:bg-opacity-80'}`}
520
+ title="Eraser"
521
+ >
522
+ <Eraser size={20} color={tool === 'eraser' ? '#ffffff' : iconColor} />
523
+ </button>
524
+ {(tool === 'pencil') && (
525
+ <div className=' flex items-center gap-2 ml-2'>
526
+ <Ruler size={16} color={iconColor} />
527
+ <input
528
+ type="range"
529
+ min="1"
530
+ max="20"
531
+ value={pencilSize}
532
+ onChange={(e) => setPencilSize(parseInt(e.target.value))}
533
+ className="w-24 h-1 bg-gray-300 rounded-lg appearance-none cursor-pointer"
534
+ title="Pencil Size"
535
+ />
536
+ <span className={'text-sm ${textColor}'}>{pencilSize}px</span>
537
+ </div>
538
+ )}
539
+ </div>
540
+
541
+ <div className="flex items-center gap-1 pl-2 border-l border-gray-200">
542
+ <button
543
+ onClick={() => setScale(1)}
544
+ className="p-2 rounded hover:bg-opacity-80"
545
+ title="Reset View"
546
+ >
547
+ <RotateCcw size={20} color={iconColor} />
548
+ </button>
549
+ <button
550
+ onClick={() => onZoom(-0.1)}
551
+ className="p-2 rounded hover:bg-opacity-80"
552
+ title="Zoom Out"
553
+ >
554
+ <ZoomOut size={20} color={iconColor} />
555
+ </button>
556
+ <span className={`text-sm min-w-[4rem] text-center ${textColor}`}>
557
+ {Math.round(scale * 100)}%
558
+ </span>
559
+ <button
560
+ onClick={() => onZoom(0.1)}
561
+ className="p-2 rounded hover:bg-opacity-80"
562
+ title="Zoom In"
563
+ >
564
+ <ZoomIn size={20} color={iconColor} />
565
+ </button>
566
+ </div>
567
+ </div>
568
+
569
+ {/* Download Button */}
570
+ {showDownloadButton && (
571
+ <button
572
+ onClick={() => {
573
+ handleDownloadRegions();
574
+ setShowDownloadButton(false);
575
+ }}
576
+ 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"
577
+ >
578
+ <Download size={20} color="#ffffff" />
579
+ Download Detected Regions
580
+ </button>
581
+ )}
582
+
583
+
584
+ {/* Canvas Container */}
585
+ <div className="absolute inset-0">
586
+ <canvas
587
+ ref={backgroundCanvasRef}
588
+ className="absolute inset-0 z-0"
589
+ />
590
+ <canvas
591
+ id="canvas"
592
+ ref={drawingCanvasRef}
593
+ onMouseDown={handleMouseDown}
594
+ onMouseMove={handleMouseMove}
595
+ onMouseUp={handleMouseUp}
596
+ className="absolute inset-0 z-1"
597
+ />
598
+ </div>
599
+
600
+ {/* Text Area for Writing */}
601
+ {action === "writing" && (
602
+ <textarea
603
+ ref={textAreaRef}
604
+ onBlur={handleBlur}
605
+ style={{
606
+ position: "fixed",
607
+ top: (selectedElement.y1 - 2) * scale + panOffset.y * scale - scaleOffset.y,
608
+ left: selectedElement.x1 * scale + panOffset.x * scale - scaleOffset.x,
609
+ font: `${24 * scale}px sans-serif`,
610
+ margin: 0,
611
+ padding: 0,
612
+ border: 0,
613
+ outline: 0,
614
+ resize: "auto",
615
+ overflow: "hidden",
616
+ whiteSpace: "pre",
617
+ background: "transparent",
618
+ color: darkMode ? 'white' : 'black',
619
+ zIndex: 2,
620
+ }}
621
+ />
622
+ )}
623
+ </div>
624
+ );
625
+ };
626
+
627
+ 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;
tailwind.config.js ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ module.exports = {
3
+ content: [
4
+ "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
5
+ "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
6
+ "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
7
+ ],
8
+ theme: {
9
+ extend: {
10
+ colors: {
11
+ background: "var(--background)",
12
+ foreground: "var(--foreground)",
13
+ },
14
+ },
15
+ },
16
+ plugins: [],
17
+ };