Este artículo profundiza en la implementación de pinceles creativos para una aplicación de lienzo digital de código abierto. El objetivo es mejorar la experiencia de usuario orfeciendo efectos de pintura únicos tanto en entornos de escritorio como móviles. La aplicación ya incluye una variedad de funciones de asistencia para el dibujo, como deshacer/rehacer, copiar/eliminar, importar/exportar, múltiples lienzos y capas.
Este es el tercer artículo de una serie que detalla la creación de estos pinceles. El código fuente completo está disponible en GitHub.
Pincel de Puntos Múltiples
Este efecto se logra generando puntos aleatorios arlededor de la posición actual del cursor durante el movimiento del ratón. Luego, se dibujan círculos en estas posiciones aleatorias. Cada nuevo conjunto de puntos se conecta al conjunto anterior con líneas, creando un efecto de conexión entre puntos dispersos.
interface Coordinates {
x: number;
y: number;
}
let drawingActive = false;
let previousPoints: Coordinates[] = []; // Almacena los puntos del círculo dibujado previamente
/**
* Genera coordenadas aleatorias dentro de un área definida.
* @param centerX - Coordenada X del centro del área.
* @param centerY - Coordenada Y del centro del área.
* @param areaSize - Dimensión del área cuadrada.
* @param numberOfPoints - Cantidad de puntos a generar.
* @returns Un array de objetos Coordinates.
*/
const generateRandomAreaCoordinates = (
centerX: number,
centerY: number,
areaSize: number,
numberOfPoints: number
): Coordinates[] => {
const halfSize = areaSize / 2;
const generatedPoints: Coordinates[] = [];
for (let i = 0; i < numberOfPoints; i++) {
const randomX = Math.floor(centerX - halfSize + Math.random() * areaSize);
const randomY = Math.floor(centerY - halfSize + Math.random() * areaSize);
generatedPoints.push({ x: randomX, y: randomY });
}
return generatedPoints;
};
function CreativeCanvas() {
const canvasElementRef = useRef<HTMLCanvasElement | null>(null);
const [renderingContext, setRenderingContext] = useState<CanvasRenderingContext2D | null>(null);
useEffect(() => {
if (canvasElementRef?.current) {
const context = canvasElementRef.current.getContext('2d');
if (context) {
context.fillStyle = '#333'; // Color de relleno
context.strokeStyle = '#333'; // Color del trazo
context.lineWidth = 1;
setRenderingContext(context);
}
}
}, [canvasElementRef]);
const handleMouseDown = () => {
if (!canvasElementRef?.current || !renderingContext) return;
drawingActive = true;
};
const handleMouseMove = (event: MouseEvent) => {
if (!canvasElementRef?.current || !renderingContext || !drawingActive) return;
const { clientX, clientY } = event;
const currentPoints = generateRandomAreaCoordinates(clientX, clientY, 60, 4); // Genera 4 puntos en un área de 60px
renderBrushStrokes(currentPoints);
previousPoints = currentPoints;
};
const renderBrushStrokes = (currentPoints: Coordinates[]) => {
if (!renderingContext) return;
// Dibuja líneas conectando puntos anteriores con los actuales
if (previousPoints.length > 0) {
previousPoints.forEach((prevPoint, index) => {
if (currentPoints[index]) {
renderingContext.beginPath();
renderingContext.save();
renderingContext.moveTo(prevPoint.x, prevPoint.y);
renderingContext.lineTo(currentPoints[index].x, currentPoints[index].y);
renderingContext.stroke();
renderingContext.restore();
}
});
}
// Dibuja círculos en las posiciones actuales
currentPoints.map((point) => {
renderingContext.beginPath();
renderingContext.save();
renderingContext.arc(point.x, point.y, 8, 0, 2 * Math.PI, false); // Círculo con radio 8
renderingContext.fill();
renderingContext.restore();
});
};
const handleMouseUp = () => {
if (!canvasElementRef?.current || !renderingContext) return;
drawingActive = false;
previousPoints = []; // Reinicia los puntos al finalizar el trazo
};
return (
<div>
<canvas
ref={canvasElementRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
width={800} // Ejemplo de tamaño del canvas
height={600}
/>
</div>
);
}
export default CreativeCanvas;
Pincel de Ondas
Este pincel simula un efecto de ondas dibujando semicírculos entre puntos consecutivos del movimiento del ratón. La orientación de cada semicírculo se alterna para crear la apariencia de una onda. El tamaño del semicírculo se ajusta dinámicamente con la velocidad del movimiento: un movimiento más rápido resulta en un semicírculo más grande.
interface PointCoords {
x: number;
y: number;
}
let isMousePressed = false;
let lastMovePoint: PointCoords = { x: 0, y: 0 }; // Guarda la última posición del cursor
let flipState = 1; // Controla la dirección de volteo del semicírculo
/**
* Calcula la distancia euclidiana entre dos puntos.
* @param startPoint - El primer punto.
* @param endPoint - El segundo punto.
* @returns La distancia calculada.
*/
const calculateDistance = (startPoint: PointCoords, endPoint: PointCoords): number => {
return Math.sqrt(Math.pow(startPoint.x - endPoint.x, 2) + Math.pow(startPoint.y - endPoint.y, 2));
};
function WaveCanvasBrush() {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [canvasContext, setCanvasContext] = useState<CanvasRenderingContext2D | null>(null);
useEffect(() => {
if (canvasRef?.current) {
const context = canvasRef.current.getContext('2d');
if (context) {
context.strokeStyle = '#007bff'; // Color azul
context.lineJoin = 'round'; // Bordes redondeados para las uniones de línea
context.lineCap = 'round'; // Extremos redondeados de las líneas
setCanvasContext(context);
}
}
}, [canvasRef]);
const handlePressDown = (event: MouseEvent) => {
if (!canvasRef?.current || !canvasContext) return;
isMousePressed = true;
const { clientX, clientY } = event;
lastMovePoint = { x: clientX, y: clientY };
};
const handleMovement = (event: MouseEvent) => {
if (!canvasRef?.current || !canvasContext || !isMousePressed) return;
const { clientX, clientY } = event;
const currentPoint: PointCoords = { x: clientX, y: clientY };
const segmentDistance = calculateDistance(lastMovePoint, currentPoint);
const centerX = (lastMovePoint.x + clientX) / 2;
const centerY = (lastMovePoint.y + clientY) / 2;
canvasContext.beginPath();
canvasContext.save();
// Calcula el ángulo entre los dos puntos
const angle = Math.atan2(clientY - lastMovePoint.y, clientX - lastMovePoint.x);
// Determina la orientación del semicírculo (volteo)
const flipOffset = (flipState % 2) * Math.PI;
// Dibuja el semicírculo
canvasContext.arc(
centerX,
centerY,
segmentDistance / 2, // Radio basado en la distancia
angle + flipOffset, // Ángulo de inicio
angle + flipOffset + Math.PI // Ángulo de fin (medio círculo)
);
canvasContext.stroke();
canvasContext.restore();
// Actualiza el estado para el próximo segmento
flipState++;
lastMovePoint = currentPoint; // Actualiza el último punto de movimiento
};
const handleReleaseUp = () => {
if (!canvasRef?.current || !canvasContext) return;
isMousePressed = false;
lastMovePoint = { x: 0, y: 0 }; // Resetea el punto de movimiento
};
return (
<div>
<canvas
ref={canvasRef}
onMouseDown={handlePressDown}
onMouseMove={handleMovement}
onMouseUp={handleReleaseUp}
width={800}
height={600}
/>
</div>
);
}
export default WaveCanvasBrush;
Pincel de Espinas
Este pincel genera un efecto de "espinas" dibujando elipses delgadas y alargadas. La longitud de la elipse se basa en la distancia recorrida por el cursor, y su grosor es fijo y pequeño. Esto crea una apariencia puntiaguda y afilada.
interface Position {
x: number;
y: number;
}
let isDrawing = false;
let previousPosition: Position = { x: 0, y: 0 }; // Almacena la última posición del ratón
const minEllipseWidth = 4; // Ancho mínimo de la elipse
/**
* Calcula la distancia entre dos puntos.
* @param start - El punto de inicio.
* @param end - El punto final.
* @returns La distancia calculada.
*/
const getPathDistance = (start: Position, end: Position): number => {
return Math.sqrt(Math.pow(start.x - end.x, 2) + Math.pow(start.y - end.y, 2));
};
function ThornBrushCanvas() {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [ctx, setCtx] = useState<CanvasRenderingContext2D | null>(null);
useEffect(() => {
if (canvasRef?.current) {
const renderingContext = canvasRef.current.getContext('2d');
if (renderingContext) {
renderingContext.fillStyle = '#d9534f'; // Color rojo
renderingContext.lineJoin = 'round';
renderingContext.lineCap = 'round';
setCtx(renderingContext);
}
}
}, [canvasRef]);
const handleInteractionStart = (event: MouseEvent) => {
if (!canvasRef?.current || !ctx) return;
isDrawing = true;
const { clientX, clientY } = event;
previousPosition = { x: clientX, y: clientY };
};
const handleInteractionMove = (event: MouseEvent) => {
if (!canvasRef?.current || !ctx || !isDrawing) return;
const { clientX, clientY } = event;
const currentPosition: Position = { x: clientX, y: clientY };
const movementDistance = getPathDistance(previousPosition, currentPosition);
// Calcula el centro de la elipse
const ellipseCenterX = (previousPosition.x + clientX) / 2;
const ellipseCenterY = (previousPosition.y + clientY) / 2;
ctx.beginPath();
ctx.save();
// Calcula el ángulo de rotación de la elipse
const rotationAngle = Math.atan2(clientY - previousPosition.y, clientX - previousPosition.x);
/**
* Dibuja la elipse:
* - Radio X (ancho) es proporcional a la distancia del movimiento.
* - Radio Y (alto) es fijo (minEllipseWidth).
*/
ctx.ellipse(
ellipseCenterX,
ellipseCenterY,
movementDistance * 6 + minEllipseWidth, // Ancho de la elipse
minEllipseWidth, // Alto de la elipse
rotationAngle, // Rotación
0, // Ángulo de inicio
2 * Math.PI // Ángulo de fin
);
ctx.fill();
ctx.restore();
previousPosition = currentPosition; // Actualiza la posición anterior
};
const handleInteractionEnd = () => {
if (!canvasRef?.current || !ctx) return;
isDrawing = false;
previousPosition = { x: 0, y: 0 }; // Resetea la posición
};
return (
<div>
<canvas
ref={canvasRef}
onMouseDown={handleInteractionStart}
onMouseMove={handleInteractionMove}
onMouseUp={handleInteractionEnd}
width={800}
height={600}
/>
</div>
);
}
export default ThornBrushCanvas;