mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-03 16:51:26 +00:00
347 lines
10 KiB
JavaScript
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;
|