255 lines
9.4 KiB
TypeScript
255 lines
9.4 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useEffect } from "react";
|
|
import { Card } from "@/components/ui/card";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Wand2 } from "lucide-react";
|
|
|
|
interface SignatureGeneratorProps {
|
|
onSignatureSelect: (dataUrl: string) => void;
|
|
}
|
|
|
|
// 서명용 손글씨 폰트 목록 (완전한 한글 지원 폰트만 사용)
|
|
const SIGNATURE_FONTS = {
|
|
korean: [
|
|
{ name: "나눔손글씨 붓", style: "'Nanum Brush Script', cursive", weight: 400 },
|
|
{ name: "나눔손글씨 펜", style: "'Nanum Pen Script', cursive", weight: 400 },
|
|
{ name: "손글씨 (Gaegu)", style: "Gaegu, cursive", weight: 700 },
|
|
{ name: "하이멜로디", style: "'Hi Melody', cursive", weight: 400 },
|
|
{ name: "감자꽃", style: "'Gamja Flower', cursive", weight: 400 },
|
|
{ name: "푸어스토리", style: "'Poor Story', cursive", weight: 400 },
|
|
{ name: "도현", style: "'Do Hyeon', sans-serif", weight: 400 },
|
|
{ name: "주아", style: "Jua, sans-serif", weight: 400 },
|
|
],
|
|
english: [
|
|
{ name: "Allura (우아한)", style: "Allura, cursive", weight: 400 },
|
|
{ name: "Dancing Script (춤추는)", style: "'Dancing Script', cursive", weight: 700 },
|
|
{ name: "Great Vibes (멋진)", style: "'Great Vibes', cursive", weight: 400 },
|
|
{ name: "Pacifico (파도)", style: "Pacifico, cursive", weight: 400 },
|
|
{ name: "Satisfy (만족)", style: "Satisfy, cursive", weight: 400 },
|
|
{ name: "Caveat (거친)", style: "Caveat, cursive", weight: 700 },
|
|
{ name: "Permanent Marker", style: "'Permanent Marker', cursive", weight: 400 },
|
|
{ name: "Shadows Into Light", style: "'Shadows Into Light', cursive", weight: 400 },
|
|
{ name: "Kalam (볼드)", style: "Kalam, cursive", weight: 700 },
|
|
{ name: "Patrick Hand", style: "'Patrick Hand', cursive", weight: 400 },
|
|
{ name: "Indie Flower", style: "'Indie Flower', cursive", weight: 400 },
|
|
{ name: "Amatic SC", style: "'Amatic SC', cursive", weight: 700 },
|
|
{ name: "Covered By Your Grace", style: "'Covered By Your Grace', cursive", weight: 400 },
|
|
],
|
|
};
|
|
|
|
export function SignatureGenerator({ onSignatureSelect }: SignatureGeneratorProps) {
|
|
const [language, setLanguage] = useState<"korean" | "english">("korean");
|
|
const [name, setName] = useState("");
|
|
const [generatedSignatures, setGeneratedSignatures] = useState<string[]>([]);
|
|
const [isGenerating, setIsGenerating] = useState(false);
|
|
const [fontsLoaded, setFontsLoaded] = useState(false);
|
|
const canvasRefs = useRef<(HTMLCanvasElement | null)[]>([]);
|
|
|
|
const fonts = SIGNATURE_FONTS[language];
|
|
|
|
// 컴포넌트 마운트 시 폰트 미리 로드
|
|
useEffect(() => {
|
|
const loadAllFonts = async () => {
|
|
try {
|
|
await document.fonts.ready;
|
|
|
|
// 모든 폰트를 명시적으로 로드
|
|
const allFonts = [...SIGNATURE_FONTS.korean, ...SIGNATURE_FONTS.english];
|
|
const fontLoadPromises = allFonts.map((font) => document.fonts.load(`${font.weight} 124px ${font.style}`));
|
|
await Promise.all(fontLoadPromises);
|
|
|
|
// 임시 Canvas를 그려서 폰트를 강제로 렌더링 (브라우저가 폰트를 실제로 사용하도록)
|
|
const tempCanvas = document.createElement("canvas");
|
|
tempCanvas.width = 100;
|
|
tempCanvas.height = 100;
|
|
const tempCtx = tempCanvas.getContext("2d");
|
|
|
|
if (tempCtx) {
|
|
for (const font of allFonts) {
|
|
tempCtx.font = `${font.weight} 124px ${font.style}`;
|
|
tempCtx.fillText("테", 0, 50);
|
|
tempCtx.fillText("A", 0, 50);
|
|
}
|
|
}
|
|
|
|
// 폰트 렌더링 후 대기
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
|
|
setFontsLoaded(true);
|
|
} catch (error) {
|
|
console.warn("Font preloading failed:", error);
|
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
setFontsLoaded(true);
|
|
}
|
|
};
|
|
|
|
loadAllFonts();
|
|
}, []);
|
|
|
|
// 서명 생성
|
|
const generateSignatures = async () => {
|
|
if (!name.trim()) return;
|
|
|
|
setIsGenerating(true);
|
|
|
|
// 폰트가 미리 로드될 때까지 대기
|
|
if (!fontsLoaded) {
|
|
await new Promise((resolve) => {
|
|
const checkInterval = setInterval(() => {
|
|
if (fontsLoaded) {
|
|
clearInterval(checkInterval);
|
|
resolve(true);
|
|
}
|
|
}, 100);
|
|
});
|
|
}
|
|
|
|
const newSignatures: string[] = [];
|
|
|
|
// 동기적으로 하나씩 생성
|
|
for (const font of fonts) {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = 500;
|
|
canvas.height = 200;
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
if (ctx) {
|
|
// 배경 흰색
|
|
ctx.fillStyle = "#ffffff";
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// 텍스트 스타일
|
|
ctx.fillStyle = "#000000";
|
|
let fontSize = 124;
|
|
ctx.font = `${font.weight} ${fontSize}px ${font.style}`;
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
|
|
// 텍스트 너비 측정 및 크기 조정 (캔버스 너비의 90% 이내로)
|
|
let textWidth = ctx.measureText(name).width;
|
|
const maxWidth = canvas.width * 0.9;
|
|
|
|
while (textWidth > maxWidth && fontSize > 30) {
|
|
fontSize -= 2;
|
|
ctx.font = `${font.weight} ${fontSize}px ${font.style}`;
|
|
textWidth = ctx.measureText(name).width;
|
|
}
|
|
|
|
// 텍스트 그리기
|
|
ctx.fillText(name, canvas.width / 2, canvas.height / 2);
|
|
|
|
// 데이터 URL로 변환
|
|
newSignatures.push(canvas.toDataURL("image/png"));
|
|
}
|
|
}
|
|
|
|
setGeneratedSignatures(newSignatures);
|
|
setIsGenerating(false);
|
|
};
|
|
|
|
// 서명 선택 (더블클릭)
|
|
const handleSignatureDoubleClick = (dataUrl: string) => {
|
|
onSignatureSelect(dataUrl);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* 언어 선택 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">맞춤 방식</Label>
|
|
<Select
|
|
value={language}
|
|
onValueChange={(value: "korean" | "english") => {
|
|
setLanguage(value);
|
|
setName(""); // 언어 변경 시 입력값 초기화
|
|
setGeneratedSignatures([]); // 생성된 서명도 초기화
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-8">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="korean">국문</SelectItem>
|
|
<SelectItem value="english">영문</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 이름 입력 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">서명:</Label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={name}
|
|
onChange={(e) => {
|
|
const input = e.target.value;
|
|
// 국문일 때는 한글, 영문일 때는 영문+숫자+공백만 허용
|
|
if (language === "korean") {
|
|
// 한글만 허용 (자음, 모음, 완성된 글자)
|
|
const koreanOnly = input.replace(/[^\u3131-\u3163\uac00-\ud7a3\s]/g, "");
|
|
setName(koreanOnly);
|
|
} else {
|
|
// 영문, 숫자, 공백만 허용
|
|
const englishOnly = input.replace(/[^a-zA-Z\s]/g, "");
|
|
setName(englishOnly);
|
|
}
|
|
}}
|
|
placeholder={language === "korean" ? "홍길동" : "John Doe"}
|
|
maxLength={14}
|
|
className="h-8 flex-1"
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
generateSignatures();
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={generateSignatures}
|
|
disabled={!name.trim() || isGenerating || !fontsLoaded}
|
|
>
|
|
<Wand2 className="mr-1 h-3 w-3" />
|
|
{!fontsLoaded ? "폰트 로딩 중..." : isGenerating ? "생성 중..." : "만들기"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 생성된 서명 목록 */}
|
|
{generatedSignatures.length > 0 && (
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">레이블 위치</Label>
|
|
<p className="text-xs text-gray-500">더블클릭하여 서명을 선택하세요</p>
|
|
<ScrollArea className="h-[300px] rounded-md border bg-white">
|
|
<div className="space-y-2 p-2">
|
|
{generatedSignatures.map((signature, index) => (
|
|
<Card
|
|
key={index}
|
|
className="group hover:border-primary cursor-pointer border-2 border-transparent p-3 transition-all"
|
|
onDoubleClick={() => handleSignatureDoubleClick(signature)}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<img
|
|
src={signature}
|
|
alt={`서명 ${index + 1}`}
|
|
className="h-auto max-h-[45px] w-auto max-w-[280px] object-contain"
|
|
/>
|
|
<p className="ml-2 text-xs text-gray-400 opacity-0 transition-opacity group-hover:opacity-100">
|
|
{fonts[index].name}
|
|
</p>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|