drawing-web-app-react/src/components/DrawingCanvas.js

438 lines
15 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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;