81da01e786
Änderungen am Eintrag (außer Fotos) entfernen Skipper- und Crew-Signaturen automatisch. Vor dem Unterschreiben erscheinen Hinweis-Banner und Bestätigung. Co-authored-by: Cursor <cursoragent@cursor.com>
245 lines
6.6 KiB
TypeScript
245 lines
6.6 KiB
TypeScript
import { useEffect, useRef, useCallback, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { Eraser } from 'lucide-react'
|
|
import { isSignatureImage } from '../utils/signatures.js'
|
|
|
|
interface SignaturePadProps {
|
|
id: string
|
|
label: string
|
|
value: string
|
|
onChange: (value: string) => void
|
|
disabled?: boolean
|
|
readOnly?: boolean
|
|
onBeforeSign?: () => Promise<boolean> | boolean
|
|
}
|
|
|
|
const STROKE_COLOR = '#0f172a'
|
|
const STROKE_WIDTH = 2.2
|
|
|
|
export default function SignaturePad({
|
|
id,
|
|
label,
|
|
value,
|
|
onChange,
|
|
disabled = false,
|
|
readOnly = false,
|
|
onBeforeSign
|
|
}: SignaturePadProps) {
|
|
const { t } = useTranslation()
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
const isDrawing = useRef(false)
|
|
const lastPoint = useRef<{ x: number; y: number } | null>(null)
|
|
const skipExternalRedraw = useRef(false)
|
|
const hasInk = useRef(false)
|
|
const [showHint, setShowHint] = useState(() => !value)
|
|
|
|
const getContext = useCallback(() => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return null
|
|
return canvas.getContext('2d')
|
|
}, [])
|
|
|
|
const clearCanvas = useCallback(() => {
|
|
const canvas = canvasRef.current
|
|
const ctx = getContext()
|
|
if (!canvas || !ctx) return
|
|
ctx.save()
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
|
ctx.restore()
|
|
hasInk.current = false
|
|
}, [getContext])
|
|
|
|
const drawImageValue = useCallback((dataUrl: string) => {
|
|
const canvas = canvasRef.current
|
|
const ctx = getContext()
|
|
if (!canvas || !ctx) return
|
|
|
|
const img = new Image()
|
|
img.onload = () => {
|
|
clearCanvas()
|
|
const width = canvas.clientWidth
|
|
const height = canvas.clientHeight
|
|
ctx.drawImage(img, 0, 0, width, height)
|
|
hasInk.current = true
|
|
}
|
|
img.src = dataUrl
|
|
}, [clearCanvas, getContext])
|
|
|
|
const setupCanvas = useCallback(() => {
|
|
const canvas = canvasRef.current
|
|
const container = containerRef.current
|
|
if (!canvas || !container) return
|
|
|
|
const rect = container.getBoundingClientRect()
|
|
const width = Math.max(rect.width, 1)
|
|
const height = Math.max(rect.height, 1)
|
|
const dpr = window.devicePixelRatio || 1
|
|
|
|
canvas.width = Math.floor(width * dpr)
|
|
canvas.height = Math.floor(height * dpr)
|
|
canvas.style.width = `${width}px`
|
|
canvas.style.height = `${height}px`
|
|
|
|
const ctx = canvas.getContext('2d')
|
|
if (!ctx) return
|
|
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
ctx.lineCap = 'round'
|
|
ctx.lineJoin = 'round'
|
|
ctx.lineWidth = STROKE_WIDTH
|
|
ctx.strokeStyle = STROKE_COLOR
|
|
|
|
if (value && isSignatureImage(value)) {
|
|
drawImageValue(value)
|
|
} else {
|
|
clearCanvas()
|
|
}
|
|
}, [clearCanvas, drawImageValue, value])
|
|
|
|
useEffect(() => {
|
|
setupCanvas()
|
|
window.addEventListener('resize', setupCanvas)
|
|
return () => window.removeEventListener('resize', setupCanvas)
|
|
}, [setupCanvas])
|
|
|
|
useEffect(() => {
|
|
if (skipExternalRedraw.current) {
|
|
skipExternalRedraw.current = false
|
|
return
|
|
}
|
|
if (value && isSignatureImage(value)) {
|
|
drawImageValue(value)
|
|
setShowHint(false)
|
|
} else if (!value) {
|
|
clearCanvas()
|
|
setShowHint(true)
|
|
}
|
|
}, [value, clearCanvas, drawImageValue])
|
|
|
|
const getPoint = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return null
|
|
const rect = canvas.getBoundingClientRect()
|
|
return {
|
|
x: event.clientX - rect.left,
|
|
y: event.clientY - rect.top
|
|
}
|
|
}
|
|
|
|
const commitCanvas = () => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return
|
|
if (!hasInk.current) {
|
|
skipExternalRedraw.current = true
|
|
onChange('')
|
|
return
|
|
}
|
|
skipExternalRedraw.current = true
|
|
onChange(canvas.toDataURL('image/png'))
|
|
}
|
|
|
|
const handlePointerDown = async (event: React.PointerEvent<HTMLCanvasElement>) => {
|
|
if (readOnly || disabled) return
|
|
event.preventDefault()
|
|
|
|
if (!value && !hasInk.current && onBeforeSign) {
|
|
const allowed = await onBeforeSign()
|
|
if (!allowed) return
|
|
}
|
|
|
|
const point = getPoint(event)
|
|
if (!point) return
|
|
|
|
isDrawing.current = true
|
|
lastPoint.current = point
|
|
setShowHint(false)
|
|
event.currentTarget.setPointerCapture(event.pointerId)
|
|
}
|
|
|
|
const handlePointerMove = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
|
if (!isDrawing.current || readOnly || disabled) return
|
|
event.preventDefault()
|
|
|
|
const point = getPoint(event)
|
|
const ctx = getContext()
|
|
const prev = lastPoint.current
|
|
if (!point || !ctx || !prev) return
|
|
|
|
ctx.beginPath()
|
|
ctx.moveTo(prev.x, prev.y)
|
|
ctx.lineTo(point.x, point.y)
|
|
ctx.stroke()
|
|
|
|
lastPoint.current = point
|
|
hasInk.current = true
|
|
}
|
|
|
|
const finishStroke = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
|
if (!isDrawing.current) return
|
|
isDrawing.current = false
|
|
lastPoint.current = null
|
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
event.currentTarget.releasePointerCapture(event.pointerId)
|
|
}
|
|
commitCanvas()
|
|
}
|
|
|
|
const handleClear = () => {
|
|
if (readOnly || disabled) return
|
|
clearCanvas()
|
|
skipExternalRedraw.current = true
|
|
setShowHint(true)
|
|
onChange('')
|
|
}
|
|
|
|
const interactive = !readOnly && !disabled
|
|
|
|
if (readOnly && value && !isSignatureImage(value)) {
|
|
return (
|
|
<div className="input-group signature-pad-group">
|
|
<label htmlFor={id}>{label}</label>
|
|
<div className="signature-legacy-text">{value}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="input-group signature-pad-group">
|
|
<div className="signature-pad-header">
|
|
<label htmlFor={id}>{label}</label>
|
|
{interactive && (
|
|
<button type="button" className="signature-pad-clear" onClick={handleClear}>
|
|
<Eraser size={14} />
|
|
{t('logs.sign_clear')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
ref={containerRef}
|
|
className={`signature-pad ${readOnly ? 'readonly' : ''} ${disabled ? 'disabled' : ''}`}
|
|
>
|
|
{readOnly && value && isSignatureImage(value) ? (
|
|
<img src={value} alt={label} className="signature-pad-image" />
|
|
) : (
|
|
<canvas
|
|
id={id}
|
|
ref={canvasRef}
|
|
className="signature-pad-canvas"
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={finishStroke}
|
|
onPointerLeave={finishStroke}
|
|
onPointerCancel={finishStroke}
|
|
/>
|
|
)}
|
|
{interactive && showHint && (
|
|
<span className="signature-pad-hint">{t('logs.sign_hint')}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|