Files
n3wt-school/Front-End/src/components/Form/SignatureField.js
2025-11-30 17:24:25 +01:00

347 lines
10 KiB
JavaScript

import React, { useRef, useEffect, useState, useCallback } from 'react';
import { RotateCcw } from 'lucide-react';
const SignatureField = ({
label = 'Signature',
required = false,
onChange,
value,
disabled = false,
readOnly = false,
backgroundColor = '#ffffff',
penColor = '#000000',
penWidth = 2,
}) => {
const canvasRef = useRef(null);
const [isDrawing, setIsDrawing] = useState(false);
const [lastPosition, setLastPosition] = useState({ x: 0, y: 0 });
const [isEmpty, setIsEmpty] = useState(true);
const [svgPaths, setSvgPaths] = useState([]);
const [currentPath, setCurrentPath] = useState('');
const [smoothingPoints, setSmoothingPoints] = useState([]);
// Initialiser le canvas
const initializeCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext('2d');
// Support High DPI / Retina displays
const devicePixelRatio = window.devicePixelRatio || 1;
const displayWidth = 400;
const displayHeight = 200;
// Ajuster la taille physique du canvas pour la haute résolution
canvas.width = displayWidth * devicePixelRatio;
canvas.height = displayHeight * devicePixelRatio;
// Maintenir la taille d'affichage
canvas.style.width = displayWidth + 'px';
canvas.style.height = displayHeight + 'px';
// Adapter le contexte à la densité de pixels
context.scale(devicePixelRatio, devicePixelRatio);
// Améliorer l'anti-aliasing et le rendu
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.textRenderingOptimization = 'optimizeQuality';
// Configuration du style de dessin
context.fillStyle = backgroundColor;
context.fillRect(0, 0, displayWidth, displayHeight);
context.strokeStyle = penColor;
context.lineWidth = penWidth;
context.lineCap = 'round';
context.lineJoin = 'round';
context.globalCompositeOperation = 'source-over';
}, [backgroundColor, penColor, penWidth]);
useEffect(() => {
initializeCanvas();
// Si une valeur est fournie (signature existante), la charger
if (value && value !== '') {
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
if (value.includes('svg+xml')) {
// Charger une signature SVG
const svgData = atob(value.split(',')[1]);
const img = new Image();
const svg = new Blob([svgData], {
type: 'image/svg+xml;charset=utf-8',
});
const url = URL.createObjectURL(svg);
img.onload = () => {
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = backgroundColor;
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(img, 0, 0);
setIsEmpty(false);
URL.revokeObjectURL(url);
};
img.src = url;
} else {
// Charger une image classique
const img = new Image();
img.onload = () => {
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = backgroundColor;
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(img, 0, 0);
setIsEmpty(false);
};
img.src = value;
}
}
}, [value, initializeCanvas, backgroundColor]);
// Obtenir les coordonnées relatives au canvas
const getCanvasPosition = (e) => {
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.style.width
? parseFloat(canvas.style.width) / rect.width
: 1;
const scaleY = canvas.style.height
? parseFloat(canvas.style.height) / rect.height
: 1;
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY,
};
};
// Obtenir les coordonnées pour les événements tactiles
const getTouchPosition = (e) => {
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.style.width
? parseFloat(canvas.style.width) / rect.width
: 1;
const scaleY = canvas.style.height
? parseFloat(canvas.style.height) / rect.height
: 1;
return {
x: (e.touches[0].clientX - rect.left) * scaleX,
y: (e.touches[0].clientY - rect.top) * scaleY,
};
};
// Commencer le dessin
const startDrawing = useCallback(
(e) => {
if (disabled || readOnly) return;
e.preventDefault();
setIsDrawing(true);
const pos = e.type.includes('touch')
? getTouchPosition(e)
: getCanvasPosition(e);
setLastPosition(pos);
// Commencer un nouveau path SVG
setCurrentPath(`M ${pos.x},${pos.y}`);
setSmoothingPoints([pos]);
},
[disabled, readOnly]
);
// Dessiner
const draw = useCallback(
(e) => {
if (!isDrawing || disabled || readOnly) return;
e.preventDefault();
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
const currentPos = e.type.includes('touch')
? getTouchPosition(e)
: getCanvasPosition(e);
// Calculer la distance pour déterminer si on doit interpoler
const distance = Math.sqrt(
Math.pow(currentPos.x - lastPosition.x, 2) +
Math.pow(currentPos.y - lastPosition.y, 2)
);
// Si la distance est grande, interpoler pour un tracé plus lisse
if (distance > 2) {
const midPoint = {
x: (lastPosition.x + currentPos.x) / 2,
y: (lastPosition.y + currentPos.y) / 2,
};
// Utiliser une courbe quadratique pour un tracé plus lisse
context.beginPath();
context.moveTo(lastPosition.x, lastPosition.y);
context.quadraticCurveTo(
lastPosition.x,
lastPosition.y,
midPoint.x,
midPoint.y
);
context.stroke();
setLastPosition(midPoint);
setCurrentPath(
(prev) =>
prev +
` Q ${lastPosition.x},${lastPosition.y} ${midPoint.x},${midPoint.y}`
);
} else {
// Tracé direct pour les mouvements lents
context.beginPath();
context.moveTo(lastPosition.x, lastPosition.y);
context.lineTo(currentPos.x, currentPos.y);
context.stroke();
setLastPosition(currentPos);
setCurrentPath((prev) => prev + ` L ${currentPos.x},${currentPos.y}`);
}
setIsEmpty(false);
},
[isDrawing, lastPosition, disabled]
);
// Arrêter le dessin
const stopDrawing = useCallback(
(e) => {
if (!isDrawing) return;
e.preventDefault();
setIsDrawing(false);
// Ajouter le path terminé aux paths SVG
if (currentPath) {
setSvgPaths((prev) => [...prev, currentPath]);
setCurrentPath('');
}
// Notifier le parent du changement avec SVG
if (onChange) {
const newPaths = [...svgPaths, currentPath].filter((p) => p.length > 0);
const svgData = generateSVG(newPaths);
onChange(svgData);
}
},
[isDrawing, onChange, svgPaths, currentPath]
);
// Générer le SVG à partir des paths
const generateSVG = (paths) => {
const svgContent = `<svg width="400" height="200" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="${backgroundColor}"/>
${paths
.map(
(path) =>
`<path d="${path}" stroke="${penColor}" stroke-width="${penWidth}" stroke-linecap="round" stroke-linejoin="round" fill="none"/>`
)
.join('\n ')}
</svg>`;
return `data:image/svg+xml;base64,${btoa(svgContent)}`;
};
// Effacer la signature
const clearSignature = () => {
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
// Effacer en tenant compte des dimensions d'affichage
const displayWidth = 400;
const displayHeight = 200;
context.clearRect(0, 0, displayWidth, displayHeight);
context.fillStyle = backgroundColor;
context.fillRect(0, 0, displayWidth, displayHeight);
setIsEmpty(true);
setSvgPaths([]);
setCurrentPath('');
setSmoothingPoints([]);
if (onChange) {
onChange('');
}
};
return (
<div className="signature-field">
{label && (
<label className="block text-sm font-medium text-gray-700 mb-2">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className="border border-gray-300 rounded-lg p-4 bg-gray-50">
<canvas
ref={canvasRef}
className={`border border-gray-200 bg-white rounded touch-none ${
readOnly ? 'cursor-default' : 'cursor-crosshair'
}`}
style={{
maxWidth: '100%',
height: 'auto',
opacity: disabled || readOnly ? 0.7 : 1,
cursor: disabled
? 'not-allowed'
: readOnly
? 'default'
: 'crosshair',
}}
onMouseDown={readOnly ? undefined : startDrawing}
onMouseMove={readOnly ? undefined : draw}
onMouseUp={readOnly ? undefined : stopDrawing}
onMouseLeave={readOnly ? undefined : stopDrawing}
onTouchStart={readOnly ? undefined : startDrawing}
onTouchMove={readOnly ? undefined : draw}
onTouchEnd={readOnly ? undefined : stopDrawing}
/>
<div className="flex justify-between items-center mt-3">
<div className="text-xs text-gray-500">
{readOnly
? isEmpty
? 'Aucune signature'
: 'Signature'
: isEmpty
? 'Signez dans la zone ci-dessus'
: 'Signature capturée'}
</div>
{!readOnly && (
<div className="flex gap-2">
<button
type="button"
onClick={clearSignature}
disabled={disabled || isEmpty}
className="flex items-center gap-1 px-3 py-1 text-xs bg-red-100 text-red-600 rounded hover:bg-red-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<RotateCcw size={12} />
Effacer
</button>
</div>
)}
</div>
{required && isEmpty && (
<div className="text-xs text-red-500 mt-1">
La signature est obligatoire
</div>
)}
</div>
</div>
);
};
export default SignatureField;