SignatureDrawing.tsx
· 2.5 KiB · TypeScript
Raw
'use client';
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
interface Props {
src?: string;
fillSrc?: string;
reverseOrder?: boolean;
}
export function SignatureDrawing({
src = "/sign.svg",
fillSrc,
reverseOrder = true,
}: Props) {
const [paths, setPaths] = useState<string[]>([]);
const svgRef = useRef<SVGSVGElement>(null);
const [showFill, setShowFill] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
let mounted = true;
fetch(src)
.then((res) => res.text())
.then((text) => {
if (!mounted) return;
const parser = new DOMParser();
const doc = parser.parseFromString(text, "image/svg+xml");
const extracted = Array.from(doc.querySelectorAll("path"))
.map((node) => node.getAttribute("d") ?? "")
.filter(Boolean);
const d = reverseOrder ? extracted.reverse() : extracted;
setShowFill(false);
setPaths(d);
})
.catch(() => setShowFill(true));
return () => {
mounted = false;
};
}, [src, reverseOrder]);
useEffect(() => {
if (!svgRef.current) return;
const nodes = svgRef.current.querySelectorAll<SVGPathElement>("path");
let cumulative = 0;
nodes.forEach((node) => {
const length = node.getTotalLength();
const duration = Math.max(0.08, length / 800);
node.style.setProperty("--path-length", `${length}`);
node.style.strokeDasharray = `${length}`;
node.style.strokeDashoffset = `${length}`;
node.style.animationDuration = `${duration}s`;
node.style.animationDelay = `${cumulative}s`;
cumulative += duration;
});
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setShowFill(true), cumulative * 1000 + 250);
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [paths]);
return (
<div className="relative w-40">
<svg
ref={svgRef}
viewBox="0 0 770 303"
className="signature-svg"
role="img"
aria-label="Handwritten signature animation"
>
{paths.map((d, idx) => (
<path key={idx} d={d} className="signature-stroke" />
))}
</svg>
{showFill && (
<Image
src={fillSrc ?? src}
alt="Handwritten signature"
width={160}
height={70}
className="signature-fill"
priority
/>
)}
</div>
);
}
| 1 | 'use client'; |
| 2 | |
| 3 | import Image from "next/image"; |
| 4 | import { useEffect, useRef, useState } from "react"; |
| 5 | |
| 6 | interface Props { |
| 7 | src?: string; |
| 8 | fillSrc?: string; |
| 9 | reverseOrder?: boolean; |
| 10 | } |
| 11 | |
| 12 | export function SignatureDrawing({ |
| 13 | src = "/sign.svg", |
| 14 | fillSrc, |
| 15 | reverseOrder = true, |
| 16 | }: Props) { |
| 17 | const [paths, setPaths] = useState<string[]>([]); |
| 18 | const svgRef = useRef<SVGSVGElement>(null); |
| 19 | const [showFill, setShowFill] = useState(false); |
| 20 | const timeoutRef = useRef<NodeJS.Timeout | null>(null); |
| 21 | |
| 22 | useEffect(() => { |
| 23 | let mounted = true; |
| 24 | fetch(src) |
| 25 | .then((res) => res.text()) |
| 26 | .then((text) => { |
| 27 | if (!mounted) return; |
| 28 | const parser = new DOMParser(); |
| 29 | const doc = parser.parseFromString(text, "image/svg+xml"); |
| 30 | const extracted = Array.from(doc.querySelectorAll("path")) |
| 31 | .map((node) => node.getAttribute("d") ?? "") |
| 32 | .filter(Boolean); |
| 33 | const d = reverseOrder ? extracted.reverse() : extracted; |
| 34 | setShowFill(false); |
| 35 | setPaths(d); |
| 36 | }) |
| 37 | .catch(() => setShowFill(true)); |
| 38 | return () => { |
| 39 | mounted = false; |
| 40 | }; |
| 41 | }, [src, reverseOrder]); |
| 42 | |
| 43 | useEffect(() => { |
| 44 | if (!svgRef.current) return; |
| 45 | const nodes = svgRef.current.querySelectorAll<SVGPathElement>("path"); |
| 46 | let cumulative = 0; |
| 47 | nodes.forEach((node) => { |
| 48 | const length = node.getTotalLength(); |
| 49 | const duration = Math.max(0.08, length / 800); |
| 50 | node.style.setProperty("--path-length", `${length}`); |
| 51 | node.style.strokeDasharray = `${length}`; |
| 52 | node.style.strokeDashoffset = `${length}`; |
| 53 | node.style.animationDuration = `${duration}s`; |
| 54 | node.style.animationDelay = `${cumulative}s`; |
| 55 | cumulative += duration; |
| 56 | }); |
| 57 | if (timeoutRef.current) clearTimeout(timeoutRef.current); |
| 58 | timeoutRef.current = setTimeout(() => setShowFill(true), cumulative * 1000 + 250); |
| 59 | return () => { |
| 60 | if (timeoutRef.current) clearTimeout(timeoutRef.current); |
| 61 | }; |
| 62 | }, [paths]); |
| 63 | |
| 64 | return ( |
| 65 | <div className="relative w-40"> |
| 66 | <svg |
| 67 | ref={svgRef} |
| 68 | viewBox="0 0 770 303" |
| 69 | className="signature-svg" |
| 70 | role="img" |
| 71 | aria-label="Handwritten signature animation" |
| 72 | > |
| 73 | {paths.map((d, idx) => ( |
| 74 | <path key={idx} d={d} className="signature-stroke" /> |
| 75 | ))} |
| 76 | </svg> |
| 77 | {showFill && ( |
| 78 | <Image |
| 79 | src={fillSrc ?? src} |
| 80 | alt="Handwritten signature" |
| 81 | width={160} |
| 82 | height={70} |
| 83 | className="signature-fill" |
| 84 | priority |
| 85 | /> |
| 86 | )} |
| 87 | </div> |
| 88 | ); |
| 89 | } |
| 90 |