drawing-model / src /app /DrawingBoard.js
qtitpc's picture
Upload 23 files
91c8a81 verified
Raw
History Blame Contribute Delete
21.8 kB
import React from 'react';
import {
Undo2,
Redo2,
Pencil,
Hand,
Square,
Eraser,
Sun,
Moon,
ZoomIn,
ZoomOut,
Grid,
GripHorizontal,
RotateCcw,
Scan,
Download,
Menu,
X,
Ruler,
Palette
} from 'lucide-react';
const PRESET_COLORS = [
'#000000', '#FFFFFF', '#FF4500', '#FFA500',
'#FF0000', '#FF1493', '#800080', '#4B0082',
'#008000', '#C0C0C0', '#FFA500', '#4169E1',
'#00FF00', '#40E0D0', '#0000FF', '#FFD700'
];
const safeLocalStorage = {
getItem: (key) => {
try {
if (typeof window !== 'undefined') {
return window.localStorage.getItem(key);
}
return null;
} catch (e) {
console.warn('localStorage is not available:', e);
return null;
}
},
setItem: (key, value) => {
try {
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, value);
}
} catch (e) {
console.warn('localStorage is not available:', e);
}
}
};
const getInitialDarkMode = () => {
if (typeof window === 'undefined') return false;
const savedMode = safeLocalStorage.getItem('drawingBoardDarkMode');
if (savedMode !== null) {
return savedMode === 'true';
}
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
};
const DrawingBoard = ({
tool,
setTool,
elements,
pencilSize,
setPencilSize,
scale,
setScale,
onZoom,
undo,
redo,
action,
selectedElement,
panOffset,
scaleOffset,
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleBlur,
captureDrawnArea,
handleDetectRegions,
handleDownloadRegions
}) => {
const textAreaRef = React.useRef(null);
const backgroundCanvasRef = React.useRef(null);
const drawingCanvasRef = React.useRef(null);
const [backgroundType, setBackgroundType] = React.useState('none');
const [gridSize, setGridSize] = React.useState(45);
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
const [showDownloadButton, setShowDownloadButton] = React.useState(false);
const [showColorPicker, setShowColorPicker] = React.useState(false);
const [currentColor, setCurrentColor] = React.useState('#000000');
const [customColor, setCustomColor] = React.useState('#000000');
const [darkMode, setDarkMode] = React.useState(getInitialDarkMode);
React.useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e) => {
const savedMode = localStorage.getItem('drawingBoardDarkMode');
if (savedMode === null) {
setDarkMode(e.matches);
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
React.useEffect(() => {
safeLocalStorage.setItem('drawingBoardDarkMode', darkMode);
drawElements();
}, [darkMode]);
React.useEffect(() => {
localStorage.setItem('drawingBoardDarkMode', darkMode);
drawElements();
}, [darkMode]);
const drawBackground = React.useCallback(() => {
const canvas = backgroundCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
ctx.fillStyle = darkMode ? '#ffffff' : '#000000';
ctx.fillRect(0, 0, width, height);
const offsetX = (panOffset.x * scale - scaleOffset.x) % (gridSize * scale);
const offsetY = (panOffset.y * scale - scaleOffset.y) % (gridSize * scale);
const gridColor = darkMode ? 'rgba(0, 0, 0, 0.6)' : 'rgba(255, 255, 255, 0.6)';
if (backgroundType === 'grid') {
const majorGridSize = gridSize *5;
const majorGridColor = darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
const minorGridColor = darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
ctx.beginPath();
ctx.strokeStyle = gridColor;
ctx.lineWidth = 0.5;
for (let x = offsetX; x < width; x += gridSize * scale) {
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
}
for (let y = offsetY; y < height; y += gridSize * scale) {
ctx.moveTo(0, y);
ctx.lineTo(width, y);
}
ctx.stroke();
ctx.beginPath();
ctx.strokeStyle = majorGridColor;
ctx.lineWidth = 1.5;
// Tính offset cho grid lớn
const majorOffsetX = offsetX - (offsetX % (majorGridSize * scale));
const majorOffsetY = offsetY - (offsetY % (majorGridSize * scale));
for (let x = majorOffsetX; x < width; x += majorGridSize * scale) {
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
}
// Vẽ các đường ngang lớn
for (let y = majorOffsetY; y < height; y += majorGridSize * scale) {
ctx.moveTo(0, y);
ctx.lineTo(width, y);
}
ctx.stroke();
} else if (backgroundType === 'dots') {
ctx.fillStyle = gridColor;
const dotSize = 2;
for (let x = offsetX; x < width; x += gridSize * scale) {
for (let y = offsetY; y < height; y += gridSize * scale) {
ctx.beginPath();
ctx.arc(x, y, 1, 0, Math.PI * 2);
ctx.fill();
}
}
}
}, [backgroundType, gridSize, scale, panOffset, scaleOffset, darkMode]);
const toggleBackground = () => {
setBackgroundType(prev => {
if (prev === 'none') return 'grid';
if (prev === 'grid') return 'dots';
return 'none';
});
};
const drawElements = React.useCallback(() => {
const canvas = drawingCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(panOffset.x * scale - scaleOffset.x, panOffset.y * scale - scaleOffset.y);
ctx.scale(scale, scale);
elements.forEach(element => {
if (element.type === 'pencil') {
ctx.beginPath();
ctx.strokeStyle = element.color || currentColor;
ctx.lineWidth = element.size;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
element.points.forEach((point, index) => {
if (index === 0) {
ctx.moveTo(point.x, point.y);
} else {
ctx.lineTo(point.x, point.y);
}
});
ctx.stroke();
} else if (element.type === 'line') {
ctx.beginPath();
ctx.strokeStyle = element.color || currentColor;
ctx.lineWidth = 2;
ctx.moveTo(element.x1, element.y1);
ctx.lineTo(element.x2, element.y2);
ctx.stroke();
} else if (element.type === 'rectangle') {
ctx.strokeStyle = element.color || currentColor;
ctx.lineWidth = 2;
ctx.strokeRect(
element.x1,
element.y1,
element.x2 - element.x1,
element.y2 - element.y1
);
} else if (element.type === 'text') {
ctx.font = '24px sans-serif';
ctx.fillStyle = element.color || currentColor;
ctx.fillText(element.text, element.x1, element.y1);
}
});
ctx.restore();
}, [elements, scale, panOffset, scaleOffset, currentColor]);
React.useEffect(() => {
drawBackground();
drawElements();
}, [drawBackground, drawElements]);
const ColorPickerButton = () => (
<div className="relative">
<button
onClick={() => setShowColorPicker(!showColorPicker)}
className="p-2 rounded hover:bg-opacity-80 flex items-center gap-1"
title="Color Picker"
>
<div
className="w-5 h-5 rounded-full border border-gray-300"
style={{ backgroundColor: currentColor }}
/>
<Palette size={16} color={iconColor} />
</button>
{showColorPicker && <ColorPicker />}
</div>
);
React.useEffect(() => {
const handleResize = () => {
if (backgroundCanvasRef.current && drawingCanvasRef.current) {
backgroundCanvasRef.current.width = window.innerWidth;
backgroundCanvasRef.current.height = window.innerHeight;
drawingCanvasRef.current.width = window.innerWidth;
drawingCanvasRef.current.height = window.innerHeight;
drawBackground();
drawElements();
}
};
window.addEventListener('resize', handleResize);
handleResize();
return () => window.removeEventListener('resize', handleResize);
}, [drawBackground, drawElements]);
const toggleDarkMode = () => {
setDarkMode(prev => !prev);
};
const handleDetectAndShowDownload = () => {
handleDetectRegions();
setShowDownloadButton(true);
setIsMenuOpen(false);
};
const iconColor = darkMode ? "#000000" : "#ffffff"; // Đảo ngược màu icon
const barBgColor = darkMode ? "bg-white" : "bg-gray-800"; // Giữ nguyên màu nền
const textColor = darkMode ? 'text-black' : 'text-white'; // Đảo ngược màu text
const handleColorChange =(color)=>{
setCurrentColor(color);
setShowColorPicker(false);
};
const handleCustomColorChange = (e) =>{
const newColor = e.target.value;
setCustomColor(newColor);
setCurrentColor(newColor);
};
const ColorPicker = () => (
<div className={`absolute top-full mt-2 p-2 ${barBgColor} rounded-lg shadow-lg`}>
<div className="grid grid-cols-4 gap-2 mb-2">
{PRESET_COLORS.map((color, index) => (
<button
key={index}
onClick={() => handleColorChange(color)}
className="w-6 h-6 rounded-full border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
style={{
backgroundColor: color,
border: currentColor === color ? '2px solid #3B82F6' : '1px solid #D1D5DB'
}}
/>
))}
</div>
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-gray-600">
<input
type="color"
value={customColor}
onChange={handleCustomColorChange}
className="w-8 h-8 p-0 border-0 rounded cursor-pointer"
/>
<input
type="text"
value={customColor.toUpperCase()}
onChange={(e) => {
const val = e.target.value;
if (/^#[0-9A-F]{0,6}$/i.test(val)) {
setCustomColor(val);
if (val.length === 7) setCurrentColor(val);
}
}}
className={`w-20 px-1 py-0.5 text-sm rounded ${
darkMode ? 'bg-gray-700 text-white' : 'bg-white text-black'
}`}
placeholder="#000000"
/>
</div>
</div>
);
return (
<div className={`relative w-full h-screen ${darkMode ? 'bg-gray-900' : 'bg-white'}`}>
{/* Corner Menu Button */}
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className={`fixed top-4 left-4 z-20 p-2 ${barBgColor} rounded-lg shadow-lg hover:bg-opacity-80`}
>
{isMenuOpen ?
<X size={24} color={iconColor} /> :
<Menu size={24} color={iconColor} />
}
</button>
{/* Corner Menu Panel */}
{isMenuOpen && (
<div className={`fixed top-16 left-4 z-20 ${barBgColor} rounded-lg shadow-lg p-4 w-64`}>
<div className="flex flex-col gap-4">
{/* Theme Toggle */}
<button
onClick={toggleDarkMode}
className={`w-full flex items-center justify-between p-2 rounded hover:bg-opacity-80 ${darkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-100'}`}
>
<span className={`text-sm ${darkMode ? 'text-gray-600' : 'text-white'}`}>
{darkMode ? 'Light Mode' : 'Dark Mode'}
</span>
{darkMode ?
<Sun size={20} color={iconColor} /> :
<Moon size={20} color={iconColor} />
}
</button>
{/* Grid Toggle */}
<button
onClick={() => setBackgroundType(prev => prev === 'grid' ? 'none' : 'grid')}
className={`w-full flex items-center justify-between p-2 rounded hover:bg-opacity-80 ${darkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-100'}`}
>
<span className={`text-sm ${darkMode ? 'text-gray-600' : 'text-white'}`}>
Show Grid
</span>
<Grid
size={20}
color={iconColor}
className={backgroundType === 'grid' ? 'opacity-100' : 'opacity-50'}
/>
</button>
<button
onClick={toggleBackground}
className={`w-full flex items-center justify-between p-2 rounded hover:bg-opacity-80 ${
darkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-100'
}`}
>
<span className={`text-sm ${darkMode ? 'text-black' : 'text-white'}`}>
{backgroundType === 'none' ? 'No Grid' :
backgroundType === 'grid' ? 'Grid Lines' : 'Grid Dots'}
</span>
<Grid
size={20}
color={iconColor}
className={backgroundType !== 'none' ? 'opacity-100' : 'opacity-50'}
/>
</button>
{backgroundType !== 'none' && (
<div className="flex flex-col gap-2 p-2">
<div className="flex items-center justify-between">
<span className={`text-sm ${darkMode ? 'text-black' : 'text-white'}`}>
Grid Size
</span>
<span className={`text-sm ${darkMode ? 'text-black' : 'text-white'}`}>
{gridSize}px
</span>
</div>
<input
type="range"
min="10"
max="100"
value={gridSize}
onChange={(e) => setGridSize(parseInt(e.target.value))}
className="w-full h-1 bg-gray-300 rounded-lg appearance-none cursor-pointer"
/>
</div>
)}
<style jsx global>{`
input[type="range"] {
-webkit-appearance: none;
height: 4px;
background: ${darkMode ? '#ffffff40' : '#00000040'};
border-radius: 2px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
background: ${darkMode ? '#ffffff' : '#000000'};
border-radius: 50%;
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 12px;
height: 12px;
background: ${darkMode ? '#ffffff' : '#000000'};
border-radius: 50%;
cursor: pointer;
border: none;
}
input[type="range"]:hover::-webkit-slider-thumb {
background: #3b82f6;
}
input[type="range"]:hover::-moz-range-thumb {
background: #3b82f6;
}
`}</style>
{/* Region Detection */}
<button
onClick={handleDetectAndShowDownload}
className={`w-full flex items-center justify-between p-2 rounded hover:bg-opacity-80 ${darkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-100'}`}
>
<span className={`text-sm ${darkMode ? 'text-gray-600' : 'text-white'}`}>
Detect Regions
</span>
<Scan size={20} color={iconColor} />
</button>
</div>
</div>
)}
{/* Main Toolbar */}
<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`}>
<div className="flex items-center gap-1 pr-2 border-r border-gray-200">
<button onClick={undo} className="p-2 rounded hover:bg-opacity-80" title="Undo">
<Undo2 size={20} color={iconColor} />
</button>
<button onClick={redo} className="p-2 rounded hover:bg-opacity-80" title="Redo">
<Redo2 size={20} color={iconColor} />
</button>
</div>
<div className="flex items-center gap-1 px-2">
<ColorPickerButton />
<button
onClick={() => setTool('selection')}
className={`p-2 rounded ${tool === 'selection' ? 'bg-blue-500' : 'hover:bg-opacity-80'}`}
title="Selection"
>
<Hand size={20} color={tool === 'selection' ? '#ffffff' : iconColor} />
</button>
<button
onClick={() => setTool('line')}
className={`p-2 rounded ${tool === 'line' ? 'bg-blue-500' : 'hover:bg-opacity-80'}`}
title="Line"
>
<GripHorizontal size={20} color={tool === 'line' ? '#ffffff' : iconColor} />
</button>
<button
onClick={() => setTool('rectangle')}
className={`p-2 rounded ${tool === 'rectangle' ? 'bg-blue-500' : 'hover:bg-opacity-80'}`}
title="Rectangle"
>
<Square size={20} color={tool === 'rectangle' ? '#ffffff' : iconColor} />
</button>
<button
onClick={() => setTool('pencil')}
className={`p-2 rounded ${tool === 'pencil' ? 'bg-blue-500' : 'hover:bg-opacity-80'}`}
title="Pencil"
>
<Pencil size={20} color={tool === 'pencil' ? '#ffffff' : iconColor} />
</button>
<button
onClick={() => setTool('eraser')}
className={`p-2 rounded ${tool === 'eraser' ? 'bg-blue-500' : 'hover:bg-opacity-80'}`}
title="Eraser"
>
<Eraser size={20} color={tool === 'eraser' ? '#ffffff' : iconColor} />
</button>
{(tool === 'pencil') && (
<div className=' flex items-center gap-2 ml-2'>
<Ruler size={16} color={iconColor} />
<input
type="range"
min="1"
max="20"
value={pencilSize}
onChange={(e) => setPencilSize(parseInt(e.target.value))}
className="w-24 h-1 bg-gray-300 rounded-lg appearance-none cursor-pointer"
title="Pencil Size"
/>
<span className={'text-sm ${textColor}'}>{pencilSize}px</span>
</div>
)}
</div>
<div className="flex items-center gap-1 pl-2 border-l border-gray-200">
<button
onClick={() => setScale(1)}
className="p-2 rounded hover:bg-opacity-80"
title="Reset View"
>
<RotateCcw size={20} color={iconColor} />
</button>
<button
onClick={() => onZoom(-0.1)}
className="p-2 rounded hover:bg-opacity-80"
title="Zoom Out"
>
<ZoomOut size={20} color={iconColor} />
</button>
<span className={`text-sm min-w-[4rem] text-center ${textColor}`}>
{Math.round(scale * 100)}%
</span>
<button
onClick={() => onZoom(0.1)}
className="p-2 rounded hover:bg-opacity-80"
title="Zoom In"
>
<ZoomIn size={20} color={iconColor} />
</button>
</div>
</div>
{/* Download Button */}
{showDownloadButton && (
<button
onClick={() => {
handleDownloadRegions();
setShowDownloadButton(false);
}}
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"
>
<Download size={20} color="#ffffff" />
Download Detected Regions
</button>
)}
{/* Canvas Container */}
<div className="absolute inset-0">
<canvas
ref={backgroundCanvasRef}
className="absolute inset-0 z-0"
/>
<canvas
id="canvas"
ref={drawingCanvasRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
className="absolute inset-0 z-1"
/>
</div>
{/* Text Area for Writing */}
{action === "writing" && (
<textarea
ref={textAreaRef}
onBlur={handleBlur}
style={{
position: "fixed",
top: (selectedElement.y1 - 2) * scale + panOffset.y * scale - scaleOffset.y,
left: selectedElement.x1 * scale + panOffset.x * scale - scaleOffset.x,
font: `${24 * scale}px sans-serif`,
margin: 0,
padding: 0,
border: 0,
outline: 0,
resize: "auto",
overflow: "hidden",
whiteSpace: "pre",
background: "transparent",
color: darkMode ? 'white' : 'black',
zIndex: 2,
}}
/>
)}
</div>
);
};
export default DrawingBoard;