438 lines
15 KiB
JavaScript
438 lines
15 KiB
JavaScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||
import { ChromePicker } from 'react-color';
|
||
|
||
// Update drawShape to handle images
|
||
const drawShape = (ctx, shape) => {
|
||
ctx.beginPath();
|
||
ctx.strokeStyle = shape.color;
|
||
ctx.lineWidth = shape.thickness;
|
||
if (shape.type === 'rectangle') {
|
||
ctx.rect(shape.x, shape.y, shape.width, shape.height);
|
||
ctx.stroke();
|
||
} else if (shape.type === 'circle') {
|
||
ctx.arc(shape.x, shape.y, shape.radius, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
} else if (shape.type === 'pencil') {
|
||
if (shape.points.length < 2) return;
|
||
ctx.moveTo(shape.points[0].x, shape.points[0].y);
|
||
for (let i = 1; i < shape.points.length; i++) {
|
||
ctx.lineTo(shape.points[i].x, shape.points[i].y);
|
||
}
|
||
ctx.stroke();
|
||
} else if (shape.type === 'image' && shape.image) {
|
||
ctx.drawImage(shape.image, shape.x, shape.y, shape.width, shape.height);
|
||
} else if (shape.type === 'text' && shape.text) {
|
||
ctx.fillStyle = shape.color;
|
||
ctx.font = `${shape.thickness * 6}px sans-serif`; // Match input font size
|
||
ctx.textBaseline = 'top';
|
||
ctx.fillText(shape.text, shape.x, shape.y);
|
||
}
|
||
};
|
||
|
||
const DrawingCanvas = () => {
|
||
const canvasRef = useRef(null);
|
||
const [isDrawing, setIsDrawing] = useState(false);
|
||
const [lastPosition, setLastPosition] = useState(null);
|
||
const [history, setHistory] = useState([]);
|
||
const [undoHistory, setUndoHistory] = useState([]);
|
||
const [shapes, setShapes] = useState([]);
|
||
const [currentTool, setCurrentTool] = useState('pencil');
|
||
const [color, setColor] = useState('#000000');
|
||
const [thickness, setThickness] = useState(2);
|
||
const [selectedIndex, setSelectedIndex] = useState(null);
|
||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||
const fileInputRef = useRef(null);
|
||
const [textInput, setTextInput] = useState('');
|
||
const [isTextInputVisible, setIsTextInputVisible] = useState(false);
|
||
const [textPosition, setTextPosition] = useState({ x: 0, y: 0 });
|
||
|
||
// Resize canvas on window resize
|
||
useEffect(() => {
|
||
const resizeCanvas = () => {
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return;
|
||
canvas.width = window.innerWidth - 300;
|
||
canvas.height = window.innerHeight - 100;
|
||
redraw();
|
||
};
|
||
window.addEventListener('resize', resizeCanvas);
|
||
resizeCanvas();
|
||
return () => window.removeEventListener('resize', resizeCanvas);
|
||
// eslint-disable-next-line
|
||
}, []);
|
||
|
||
// Redraw everything
|
||
const redraw = useCallback(() => {
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
shapes.forEach(shape => drawShape(ctx, shape));
|
||
history.forEach(action => {
|
||
if (action.type === 'stroke') {
|
||
ctx.putImageData(action.imageData, 0, 0);
|
||
}
|
||
});
|
||
|
||
// After drawing all shapes
|
||
if (selectedIndex !== null && shapes[selectedIndex]) {
|
||
const ctx = canvasRef.current.getContext('2d');
|
||
const shape = shapes[selectedIndex];
|
||
ctx.save();
|
||
ctx.strokeStyle = 'red';
|
||
ctx.lineWidth = 2;
|
||
if (shape.type === 'rectangle' || shape.type === 'image' || shape.type === 'text') {
|
||
ctx.strokeRect(shape.x, shape.y, shape.width, shape.height);
|
||
// Draw resize handles (corners)
|
||
const handleSize = 8;
|
||
const handles = [
|
||
[shape.x, shape.y],
|
||
[shape.x + shape.width, shape.y],
|
||
[shape.x, shape.y + shape.height],
|
||
[shape.x + shape.width, shape.y + shape.height],
|
||
];
|
||
ctx.fillStyle = 'white';
|
||
ctx.strokeStyle = 'black';
|
||
handles.forEach(([hx, hy]) => {
|
||
ctx.fillRect(hx - handleSize/2, hy - handleSize/2, handleSize, handleSize);
|
||
ctx.strokeRect(hx - handleSize/2, hy - handleSize/2, handleSize, handleSize);
|
||
});
|
||
} else if (shape.type === 'circle') {
|
||
ctx.beginPath();
|
||
ctx.arc(shape.x, shape.y, shape.radius, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
// Draw handle at the edge (right)
|
||
const handleSize = 8;
|
||
ctx.fillStyle = 'white';
|
||
ctx.strokeStyle = 'black';
|
||
ctx.fillRect(shape.x + shape.radius - handleSize/2, shape.y - handleSize/2, handleSize, handleSize);
|
||
ctx.strokeRect(shape.x + shape.radius - handleSize/2, shape.y - handleSize/2, handleSize, handleSize);
|
||
}
|
||
ctx.restore();
|
||
}
|
||
}, [shapes, history, selectedIndex]);
|
||
|
||
useEffect(() => {
|
||
redraw();
|
||
}, [shapes, history, redraw]);
|
||
|
||
// Get mouse or touch position
|
||
const getPos = (e) => {
|
||
const canvas = canvasRef.current;
|
||
const rect = canvas.getBoundingClientRect();
|
||
if (e.touches) {
|
||
return {
|
||
x: e.touches[0].clientX - rect.left,
|
||
y: e.touches[0].clientY - rect.top,
|
||
};
|
||
}
|
||
return {
|
||
x: e.clientX - rect.left,
|
||
y: e.clientY - rect.top,
|
||
};
|
||
};
|
||
|
||
const handlePointerDown = (e) => {
|
||
e.preventDefault();
|
||
const { x, y } = getPos(e);
|
||
|
||
if (currentTool === 'text') {
|
||
setTextPosition({ x, y });
|
||
setIsTextInputVisible(true);
|
||
setTextInput('');
|
||
return;
|
||
}
|
||
|
||
if (currentTool === 'select') {
|
||
// Find the topmost shape under the pointer
|
||
for (let i = shapes.length - 1; i >= 0; i--) {
|
||
const shape = shapes[i];
|
||
if (
|
||
(shape.type === 'rectangle' &&
|
||
x >= shape.x && x <= shape.x + shape.width &&
|
||
y >= shape.y && y <= shape.y + shape.height) ||
|
||
(shape.type === 'circle' &&
|
||
Math.sqrt((x - shape.x) ** 2 + (y - shape.y) ** 2) <= shape.radius) ||
|
||
(shape.type === 'image' &&
|
||
x >= shape.x && x <= shape.x + shape.width &&
|
||
y >= shape.y && y <= shape.y + shape.height)
|
||
) {
|
||
setSelectedIndex(i);
|
||
setOffset({ x: x - shape.x, y: y - shape.y });
|
||
setIsDrawing(true);
|
||
return;
|
||
}
|
||
}
|
||
setSelectedIndex(null);
|
||
setIsDrawing(false);
|
||
return;
|
||
}
|
||
|
||
setIsDrawing(true);
|
||
|
||
if (currentTool === 'pencil') {
|
||
// Start a new pencil stroke
|
||
setShapes(prev => [
|
||
...prev,
|
||
{
|
||
type: 'pencil',
|
||
color,
|
||
thickness,
|
||
points: [{ x, y }]
|
||
}
|
||
]);
|
||
} else if (currentTool === 'rectangle' || currentTool === 'circle') {
|
||
const newShape = {
|
||
type: currentTool,
|
||
x,
|
||
y,
|
||
width: 0,
|
||
height: 0,
|
||
radius: 0,
|
||
color,
|
||
thickness,
|
||
};
|
||
setShapes(prev => [...prev, newShape]);
|
||
}
|
||
};
|
||
|
||
const handlePointerMove = (e) => {
|
||
if (!isDrawing) return;
|
||
const { x, y } = getPos(e);
|
||
|
||
if (currentTool === 'select' && selectedIndex !== null) {
|
||
setShapes(prev => {
|
||
const updated = [...prev];
|
||
const shape = { ...updated[selectedIndex] };
|
||
if (shape.type === 'pencil') {
|
||
// Calculate movement delta
|
||
const dx = x - offset.x - shape.points[0].x;
|
||
const dy = y - offset.y - shape.points[0].y;
|
||
shape.points = shape.points.map(pt => ({
|
||
x: pt.x + dx,
|
||
y: pt.y + dy
|
||
}));
|
||
// Update offset so dragging is smooth
|
||
setOffset({
|
||
x: x - shape.points[0].x,
|
||
y: y - shape.points[0].y
|
||
});
|
||
} else {
|
||
shape.x = x - offset.x;
|
||
shape.y = y - offset.y;
|
||
}
|
||
updated[selectedIndex] = shape;
|
||
return updated;
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (currentTool === 'pencil') {
|
||
setShapes(prev => {
|
||
if (prev.length === 0) return prev;
|
||
const updated = [...prev];
|
||
const lastStroke = { ...updated[updated.length - 1] };
|
||
lastStroke.points = [...lastStroke.points, { x, y }];
|
||
updated[updated.length - 1] = lastStroke;
|
||
return updated;
|
||
});
|
||
} else if (currentTool === 'rectangle' || currentTool === 'circle') {
|
||
setShapes(prev => {
|
||
if (prev.length === 0) return prev;
|
||
const updated = [...prev];
|
||
const shape = { ...updated[updated.length - 1] };
|
||
if (currentTool === 'rectangle') {
|
||
shape.width = x - shape.x;
|
||
shape.height = y - shape.y;
|
||
} else if (currentTool === 'circle') {
|
||
shape.radius = Math.sqrt(Math.pow(x - shape.x, 2) + Math.pow(y - shape.y, 2));
|
||
}
|
||
updated[updated.length - 1] = shape;
|
||
return updated;
|
||
});
|
||
}
|
||
};
|
||
|
||
const deepCopyShapes = (shapesArr) => JSON.parse(JSON.stringify(shapesArr));
|
||
|
||
const handlePointerUp = () => {
|
||
setIsDrawing(false);
|
||
setLastPosition(null);
|
||
setSelectedIndex(null);
|
||
// Save a deep copy of shapes for undo/redo
|
||
setHistory(prev => [...prev, deepCopyShapes(shapes)]);
|
||
setUndoHistory([]);
|
||
};
|
||
|
||
// Undo: restore the previous snapshot
|
||
const handleUndo = () => {
|
||
if (history.length === 0) return;
|
||
setUndoHistory(prev => [...prev, deepCopyShapes(shapes)]);
|
||
const prevShapes = history[history.length - 1];
|
||
setShapes(deepCopyShapes(prevShapes));
|
||
setHistory(prev => prev.slice(0, -1));
|
||
};
|
||
|
||
// Redo: restore the next snapshot
|
||
const handleRedo = () => {
|
||
if (undoHistory.length === 0) return;
|
||
setHistory(prev => [...prev, deepCopyShapes(shapes)]);
|
||
const nextShapes = undoHistory[undoHistory.length - 1];
|
||
setShapes(deepCopyShapes(nextShapes));
|
||
setUndoHistory(prev => prev.slice(0, -1));
|
||
};
|
||
|
||
// Touch events for mobile
|
||
useEffect(() => {
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return;
|
||
canvas.addEventListener('touchstart', handlePointerDown, { passive: false });
|
||
canvas.addEventListener('touchmove', handlePointerMove, { passive: false });
|
||
canvas.addEventListener('touchend', handlePointerUp, { passive: false });
|
||
return () => {
|
||
canvas.removeEventListener('touchstart', handlePointerDown);
|
||
canvas.removeEventListener('touchmove', handlePointerMove);
|
||
canvas.removeEventListener('touchend', handlePointerUp);
|
||
};
|
||
// eslint-disable-next-line
|
||
}, [isDrawing, lastPosition, currentTool, color, thickness, shapes]);
|
||
|
||
// Insert Image Handler
|
||
const handleInsertImage = (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const img = new window.Image();
|
||
img.onload = () => {
|
||
// You can set default size or let user drag to size
|
||
const x = 50, y = 50, width = img.width > 300 ? 300 : img.width, height = img.height > 300 ? 300 : img.height;
|
||
setShapes(prev => [
|
||
...prev,
|
||
{
|
||
type: 'image',
|
||
x,
|
||
y,
|
||
width,
|
||
height,
|
||
image: img
|
||
}
|
||
]);
|
||
// Save to history for undo
|
||
setHistory(prev => [...prev, deepCopyShapes([...shapes, { type: 'image', x, y, width, height, image: img }])]);
|
||
setUndoHistory([]);
|
||
};
|
||
img.src = URL.createObjectURL(file);
|
||
// Reset input so same file can be uploaded again
|
||
e.target.value = '';
|
||
};
|
||
|
||
// Save Canvas Handler
|
||
const handleSaveCanvas = () => {
|
||
const canvas = canvasRef.current;
|
||
const link = document.createElement('a');
|
||
link.download = 'drawing.png';
|
||
link.href = canvas.toDataURL('image/png');
|
||
link.click();
|
||
};
|
||
|
||
const getCursor = () => {
|
||
switch (currentTool) {
|
||
case 'pencil':
|
||
return 'crosshair';
|
||
case 'rectangle':
|
||
case 'circle':
|
||
return 'crosshair';
|
||
case 'select':
|
||
return 'move';
|
||
default:
|
||
return 'default';
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="drawing-container">
|
||
<div className="toolbar">
|
||
<button onClick={() => setCurrentTool('pencil')} className={`tool-button ${currentTool === 'pencil' ? 'active' : ''}`}>✏️</button>
|
||
<button onClick={() => setCurrentTool('rectangle')} className={`tool-button ${currentTool === 'rectangle' ? 'active' : ''}`}>🟨</button>
|
||
<button onClick={() => setCurrentTool('circle')} className={`tool-button ${currentTool === 'circle' ? 'active' : ''}`}>⭕</button>
|
||
<button onClick={() => setCurrentTool('select')} className={`tool-button ${currentTool === 'select' ? 'active' : ''}`}>🖱️</button>
|
||
<button onClick={() => setCurrentTool('text')} className={`tool-button ${currentTool === 'text' ? 'active' : ''}`}>🔤</button>
|
||
<button onClick={handleUndo} className="tool-button" disabled={history.length === 0}>↺</button>
|
||
<button onClick={handleRedo} className="tool-button" disabled={undoHistory.length === 0}>↻</button>
|
||
<button onClick={() => fileInputRef.current.click()} className="tool-button">🖼️ Insert Image</button>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
style={{ display: 'none' }}
|
||
ref={fileInputRef}
|
||
onChange={handleInsertImage}
|
||
/>
|
||
<button onClick={handleSaveCanvas} className="tool-button">💾 Save</button>
|
||
<div className="color-picker">
|
||
<ChromePicker color={color} onChangeComplete={(c) => setColor(c.hex)} />
|
||
</div>
|
||
<div className="thickness-controls">
|
||
<button onClick={() => setThickness(t => Math.max(1, t - 1))} disabled={thickness <= 1}>➖</button>
|
||
<span>{thickness}</span>
|
||
<button onClick={() => setThickness(t => t + 1)}>➕</button>
|
||
</div>
|
||
</div>
|
||
<div className="canvas-container">
|
||
<canvas
|
||
ref={canvasRef}
|
||
className="drawing-canvas"
|
||
style={{ cursor: getCursor() }}
|
||
onMouseDown={handlePointerDown}
|
||
onMouseMove={handlePointerMove}
|
||
onMouseUp={handlePointerUp}
|
||
/>
|
||
{isTextInputVisible && (
|
||
<input
|
||
type="text"
|
||
value={textInput}
|
||
autoFocus
|
||
style={{
|
||
position: 'absolute',
|
||
left: textPosition.x + (canvasRef.current?.offsetLeft || 0),
|
||
top: textPosition.y + (canvasRef.current?.offsetTop || 0),
|
||
zIndex: 10,
|
||
fontSize: `${thickness * 6}px`
|
||
}}
|
||
onChange={e => setTextInput(e.target.value)}
|
||
onBlur={() => {
|
||
if (textInput.trim() !== '') {
|
||
setShapes(prev => [
|
||
...prev,
|
||
{
|
||
type: 'text',
|
||
x: textPosition.x,
|
||
y: textPosition.y,
|
||
text: textInput,
|
||
color,
|
||
thickness,
|
||
}
|
||
]);
|
||
setHistory(prev => [...prev, deepCopyShapes([...shapes, {
|
||
type: 'text',
|
||
x: textPosition.x,
|
||
y: textPosition.y,
|
||
text: textInput,
|
||
color,
|
||
thickness,
|
||
}])]);
|
||
setUndoHistory([]);
|
||
}
|
||
setIsTextInputVisible(false);
|
||
setTextInput('');
|
||
}}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter') e.target.blur();
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default DrawingCanvas;
|