823 lines
35 KiB
TypeScript
823 lines
35 KiB
TypeScript
import { CustomButton } from './custom/CustomButton';
|
|
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
|
|
|
|
|
/* ─── Types ─────────────────────────────────────────────── */
|
|
interface SignatureModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onConfirm: (dataUrl: string, aspect: number) => void;
|
|
}
|
|
|
|
type Mode = 'draw' | 'type' | 'upload' | 'saved';
|
|
|
|
interface SavedSig {
|
|
id: string;
|
|
dataUrl: string;
|
|
aspect: number;
|
|
label: string;
|
|
createdAt: number;
|
|
}
|
|
|
|
/* ─── Constants ──────────────────────────────────────────── */
|
|
const STORAGE_KEY = 'pdf_saved_signatures';
|
|
const CANVAS_W = 960;
|
|
const CANVAS_H = 320;
|
|
|
|
const SIGNATURE_FONTS: { label: string; family: string; css: string }[] = [
|
|
{ label: 'Script', family: 'Great Vibes', css: '"Great Vibes", cursive' },
|
|
{ label: 'Elegant', family: 'Pacifico', css: '"Pacifico", cursive' },
|
|
{ label: 'Classic', family: 'Dancing Script', css: '"Dancing Script", cursive' },
|
|
{ label: 'Formal', family: 'Pinyon Script', css: '"Pinyon Script", cursive' },
|
|
{ label: 'Bold', family: 'Satisfy', css: '"Satisfy", cursive' },
|
|
{ label: 'Handwrite', family: 'Caveat', css: '"Caveat", cursive' },
|
|
];
|
|
|
|
const INK_COLORS = [
|
|
{ label: 'Black', value: '#0f172a' },
|
|
{ label: 'Navy', value: '#1e3a8a' },
|
|
{ label: 'Blue', value: '#2563eb' },
|
|
{ label: 'Ink', value: '#312e81' },
|
|
];
|
|
|
|
const THICKNESS_OPTIONS = [
|
|
{ label: 'Thin', value: 1.5 },
|
|
{ label: 'Medium', value: 2.8 },
|
|
{ label: 'Thick', value: 4.5 },
|
|
];
|
|
|
|
/* ─── Google Fonts loader ────────────────────────────────── */
|
|
function useGoogleFonts() {
|
|
useEffect(() => {
|
|
const id = 'sig-google-fonts';
|
|
if (document.getElementById(id)) return;
|
|
const link = document.createElement('link');
|
|
link.id = id;
|
|
link.rel = 'stylesheet';
|
|
link.href =
|
|
'https://fonts.googleapis.com/css2?family=Great+Vibes&family=Pacifico&family=Dancing+Script:wght@700&family=Pinyon+Script&family=Satisfy&family=Caveat:wght@700&display=swap';
|
|
document.head.appendChild(link);
|
|
}, []);
|
|
}
|
|
|
|
/* ─── Saved signatures helpers ───────────────────────────── */
|
|
function loadSaved(): SavedSig[] {
|
|
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); }
|
|
catch { return []; }
|
|
}
|
|
function persistSaved(sigs: SavedSig[]) {
|
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(sigs.slice(0, 8))); } catch {}
|
|
}
|
|
|
|
/* ─── Point smoothing (Catmull-Rom) ─────────────────────── */
|
|
type Pt = { x: number; y: number; p: number };
|
|
|
|
function midPt(a: Pt, b: Pt): Pt {
|
|
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, p: (a.p + b.p) / 2 };
|
|
}
|
|
|
|
function strokePoints(ctx: CanvasRenderingContext2D, pts: Pt[], color: string, baseThickness: number) {
|
|
if (pts.length < 2) return;
|
|
ctx.lineCap = 'round';
|
|
ctx.lineJoin = 'round';
|
|
ctx.strokeStyle = color;
|
|
|
|
for (let i = 1; i < pts.length; i++) {
|
|
const prev = pts[i - 1];
|
|
const curr = pts[i];
|
|
const mid = midPt(prev, curr);
|
|
const pressure = (prev.p + curr.p) / 2;
|
|
ctx.lineWidth = baseThickness * (0.6 + pressure * 0.8);
|
|
|
|
ctx.beginPath();
|
|
if (i === 1) {
|
|
ctx.moveTo(prev.x, prev.y);
|
|
ctx.lineTo(mid.x, mid.y);
|
|
} else {
|
|
const prevMid = midPt(pts[i - 2], prev);
|
|
ctx.moveTo(prevMid.x, prevMid.y);
|
|
ctx.quadraticCurveTo(prev.x, prev.y, mid.x, mid.y);
|
|
}
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
/* ────────────────────────────────────────────────────────── */
|
|
export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, onConfirm }) => {
|
|
useGoogleFonts();
|
|
|
|
/* tabs */
|
|
const [mode, setMode] = useState<Mode>('draw');
|
|
|
|
/* draw */
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const strokes = useRef<Pt[][]>([]);
|
|
const currentStroke = useRef<Pt[]>([]);
|
|
const drawing = useRef(false);
|
|
const hasInk = useRef(false);
|
|
const [inkColor, setInkColor] = useState(INK_COLORS[0].value);
|
|
const [thickness, setThickness] = useState(THICKNESS_OPTIONS[1].value);
|
|
|
|
/* type */
|
|
const [typed, setTyped] = useState('');
|
|
const [fontIdx, setFontIdx] = useState(0);
|
|
const [typeColor, setTypeColor] = useState(INK_COLORS[0].value);
|
|
|
|
/* upload */
|
|
const [uploaded, setUploaded] = useState<{ url: string; aspect: number } | null>(null);
|
|
const [dragging, setDragging] = useState(false);
|
|
|
|
/* saved */
|
|
const [savedSigs, setSavedSigs] = useState<SavedSig[]>([]);
|
|
|
|
/* load saved on open */
|
|
useEffect(() => { if (open) setSavedSigs(loadSaved()); }, [open]);
|
|
|
|
/* redraw canvas after color/thickness change */
|
|
const redrawAll = useCallback(() => {
|
|
const c = canvasRef.current;
|
|
if (!c) return;
|
|
const ctx = c.getContext('2d')!;
|
|
ctx.clearRect(0, 0, c.width, c.height);
|
|
for (const stroke of strokes.current) {
|
|
strokePoints(ctx, stroke, inkColor, thickness);
|
|
}
|
|
}, [inkColor, thickness]);
|
|
|
|
useEffect(() => { if (mode === 'draw') redrawAll(); }, [inkColor, thickness, mode, redrawAll]);
|
|
|
|
/* ── reset on close ── */
|
|
const resetLocal = () => {
|
|
setMode('draw');
|
|
setTyped('');
|
|
setUploaded(null);
|
|
hasInk.current = false;
|
|
strokes.current = [];
|
|
currentStroke.current = [];
|
|
const c = canvasRef.current;
|
|
if (c) c.getContext('2d')!.clearRect(0, 0, c.width, c.height);
|
|
};
|
|
const handleClose = () => { resetLocal(); onClose(); };
|
|
|
|
/* ── canvas clear ── */
|
|
const clearCanvas = () => {
|
|
strokes.current = [];
|
|
currentStroke.current = [];
|
|
hasInk.current = false;
|
|
const c = canvasRef.current;
|
|
if (c) c.getContext('2d')!.clearRect(0, 0, c.width, c.height);
|
|
};
|
|
|
|
/* ── pointer helpers ── */
|
|
const canvasPos = (e: React.PointerEvent): Pt => {
|
|
const c = canvasRef.current!;
|
|
const r = c.getBoundingClientRect();
|
|
return {
|
|
x: (e.clientX - r.left) * (c.width / r.width),
|
|
y: (e.clientY - r.top) * (c.height / r.height),
|
|
p: e.pressure > 0 ? e.pressure : 0.5,
|
|
};
|
|
};
|
|
|
|
const onDown = (e: React.PointerEvent) => {
|
|
drawing.current = true;
|
|
const pt = canvasPos(e);
|
|
currentStroke.current = [pt];
|
|
(e.target as Element).setPointerCapture(e.pointerId);
|
|
};
|
|
|
|
const onMove = (e: React.PointerEvent) => {
|
|
if (!drawing.current) return;
|
|
const pt = canvasPos(e);
|
|
currentStroke.current.push(pt);
|
|
const ctx = canvasRef.current!.getContext('2d')!;
|
|
const stroke = currentStroke.current;
|
|
strokePoints(ctx, stroke.slice(-3), inkColor, thickness);
|
|
hasInk.current = true;
|
|
};
|
|
|
|
const onUp = () => {
|
|
if (drawing.current && currentStroke.current.length > 0) {
|
|
strokes.current.push([...currentStroke.current]);
|
|
currentStroke.current = [];
|
|
}
|
|
drawing.current = false;
|
|
};
|
|
|
|
/* ── build final dataUrl from typed ── */
|
|
const buildTypedCanvas = () => {
|
|
const c = document.createElement('canvas');
|
|
c.width = 900; c.height = 240;
|
|
const ctx = c.getContext('2d')!;
|
|
ctx.clearRect(0, 0, c.width, c.height);
|
|
ctx.fillStyle = typeColor;
|
|
ctx.font = `bold 100px ${SIGNATURE_FONTS[fontIdx].css}`;
|
|
ctx.textBaseline = 'middle';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(typed.trim() || 'Preview', c.width / 2, c.height / 2);
|
|
return { dataUrl: c.toDataURL('image/png'), aspect: c.width / c.height };
|
|
};
|
|
|
|
/* ── confirm ── */
|
|
const handleConfirm = () => {
|
|
let dataUrl = '';
|
|
let aspect = 3;
|
|
|
|
if (mode === 'draw') {
|
|
if (!hasInk.current) return;
|
|
const c = canvasRef.current!;
|
|
dataUrl = c.toDataURL('image/png');
|
|
aspect = c.width / c.height;
|
|
} else if (mode === 'type') {
|
|
if (!typed.trim()) return;
|
|
const res = buildTypedCanvas();
|
|
dataUrl = res.dataUrl; aspect = res.aspect;
|
|
} else if (mode === 'upload') {
|
|
if (!uploaded) return;
|
|
dataUrl = uploaded.url; aspect = uploaded.aspect;
|
|
} else if (mode === 'saved') {
|
|
return;
|
|
}
|
|
|
|
saveToStorage(dataUrl, aspect);
|
|
onConfirm(dataUrl, aspect);
|
|
resetLocal();
|
|
};
|
|
|
|
const saveToStorage = (dataUrl: string, aspect: number) => {
|
|
const prev = loadSaved();
|
|
const entry: SavedSig = {
|
|
id: `sig_${Date.now()}`,
|
|
dataUrl,
|
|
aspect,
|
|
label: mode === 'type' ? (typed.trim() || 'Signature') : 'Signature',
|
|
createdAt: Date.now(),
|
|
};
|
|
const updated = [entry, ...prev.filter((s) => s.dataUrl !== dataUrl)];
|
|
persistSaved(updated);
|
|
};
|
|
|
|
const handleUseSaved = (s: SavedSig) => {
|
|
saveToStorage(s.dataUrl, s.aspect); // bump to top
|
|
onConfirm(s.dataUrl, s.aspect);
|
|
resetLocal();
|
|
};
|
|
|
|
const handleDeleteSaved = (id: string) => {
|
|
const updated = savedSigs.filter((s) => s.id !== id);
|
|
setSavedSigs(updated);
|
|
persistSaved(updated);
|
|
};
|
|
|
|
/* ── upload handlers ── */
|
|
const processFile = (f: File) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const url = reader.result as string;
|
|
const img = new Image();
|
|
img.onload = () => setUploaded({ url, aspect: img.width / img.height });
|
|
img.src = url;
|
|
};
|
|
reader.readAsDataURL(f);
|
|
};
|
|
|
|
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const f = e.target.files?.[0];
|
|
if (f) processFile(f);
|
|
e.target.value = '';
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault(); setDragging(false);
|
|
const f = e.dataTransfer.files[0];
|
|
if (f && f.type.startsWith('image/')) processFile(f);
|
|
};
|
|
|
|
/* ── tab labels ── */
|
|
const tabs: { id: Mode; label: string; icon: React.ReactNode }[] = [
|
|
{
|
|
id: 'draw', label: 'Draw',
|
|
icon: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>,
|
|
},
|
|
{
|
|
id: 'type', label: 'Type',
|
|
icon: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg>,
|
|
},
|
|
{
|
|
id: 'upload', label: 'Upload',
|
|
icon: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 16 12 12 8 16"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"/></svg>,
|
|
},
|
|
{
|
|
id: 'saved', label: 'Saved',
|
|
icon: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>,
|
|
},
|
|
];
|
|
|
|
/* ── Baseline guide on canvas ── */
|
|
useEffect(() => {
|
|
if (!open || mode !== 'draw') return;
|
|
const c = canvasRef.current;
|
|
if (!c || hasInk.current || strokes.current.length > 0) return;
|
|
const ctx = c.getContext('2d')!;
|
|
ctx.clearRect(0, 0, c.width, c.height);
|
|
// baseline guide
|
|
ctx.beginPath();
|
|
ctx.setLineDash([8, 10]);
|
|
ctx.strokeStyle = '#c7d2e0';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.moveTo(60, c.height * 0.72);
|
|
ctx.lineTo(c.width - 60, c.height * 0.72);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
}, [open, mode]);
|
|
|
|
/* ── Render ── */
|
|
if (!open) return null;
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-[200] flex items-center justify-center p-4"
|
|
style={{ background: 'rgba(10,15,25,0.55)', backdropFilter: 'blur(4px)' }}
|
|
onMouseDown={handleClose}
|
|
>
|
|
<div
|
|
className="relative flex flex-col overflow-hidden"
|
|
style={{
|
|
width: 660,
|
|
maxHeight: '92vh',
|
|
borderRadius: 16,
|
|
background: '#ffffff',
|
|
boxShadow: '0 32px 80px rgba(10,15,30,0.28), 0 0 0 1px rgba(0,0,0,0.07)',
|
|
animation: 'sigModalIn 0.22s cubic-bezier(0.34,1.4,0.64,1)',
|
|
}}
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
>
|
|
{/* ── Header ── */}
|
|
<div
|
|
style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
padding: '18px 24px 16px',
|
|
borderBottom: '1px solid #edf0f5',
|
|
background: '#ffffff',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<div style={{
|
|
width: 34, height: 34, borderRadius: 8,
|
|
background: '#eef4ff',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}}>
|
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#2563eb" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M12 20h9"/>
|
|
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h2 style={{ fontSize: 15, fontWeight: 700, color: '#0f172a', margin: 0, lineHeight: 1.2 }}>
|
|
Add Signature
|
|
</h2>
|
|
<p style={{ fontSize: 11, color: '#94a3b8', margin: 0, marginTop: 1 }}>
|
|
Draw, type, or upload your signature
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={handleClose}
|
|
style={{
|
|
width: 30, height: 30, borderRadius: 8, border: '1px solid #edf0f5',
|
|
background: '#f8fafc', cursor: 'pointer', display: 'flex',
|
|
alignItems: 'center', justifyContent: 'center', color: '#64748b',
|
|
transition: 'all 0.15s',
|
|
}}
|
|
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = '#f1f5f9'; (e.currentTarget as HTMLButtonElement).style.color = '#0f172a'; }}
|
|
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = '#f8fafc'; (e.currentTarget as HTMLButtonElement).style.color = '#64748b'; }}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* ── Tab bar ── */}
|
|
<div style={{ display: 'flex', gap: 4, padding: '12px 20px 0', background: '#f8fafc', borderBottom: '1px solid #edf0f5' }}>
|
|
{tabs.map((t) => (
|
|
<button
|
|
key={t.id}
|
|
onClick={() => setMode(t.id)}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 6,
|
|
padding: '8px 16px', borderRadius: '8px 8px 0 0',
|
|
border: 'none', cursor: 'pointer', fontSize: 13, fontWeight: 600,
|
|
transition: 'all 0.15s',
|
|
background: mode === t.id ? '#ffffff' : 'transparent',
|
|
color: mode === t.id ? '#2563eb' : '#64748b',
|
|
borderBottom: mode === t.id ? '2px solid #2563eb' : '2px solid transparent',
|
|
boxShadow: mode === t.id ? '0 -2px 12px rgba(37,99,235,0.07), inset 0 0 0 1px rgba(37,99,235,0.08)' : 'none',
|
|
position: 'relative', bottom: -1,
|
|
}}
|
|
>
|
|
{t.icon}
|
|
{t.label}
|
|
{t.id === 'saved' && savedSigs.length > 0 && (
|
|
<span style={{
|
|
background: '#2563eb', color: '#fff', borderRadius: 20,
|
|
fontSize: 9, fontWeight: 700, padding: '1px 5px', lineHeight: 1.6,
|
|
}}>
|
|
{savedSigs.length}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* ── Body ── */}
|
|
<div style={{ padding: '20px 24px', overflowY: 'auto', flex: 1, minHeight: 0 }}>
|
|
|
|
{/* ══ DRAW ══ */}
|
|
{mode === 'draw' && (
|
|
<div>
|
|
{/* Controls row */}
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 24 }}>
|
|
{/* Ink color */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
<span style={{ fontSize: 11, fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Ink</span>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
{INK_COLORS.map((c) => (
|
|
<button
|
|
key={c.value}
|
|
title={c.label}
|
|
onClick={() => setInkColor(c.value)}
|
|
style={{
|
|
width: 24, height: 24, borderRadius: '50%',
|
|
background: c.value, border: 'none', cursor: 'pointer',
|
|
outline: inkColor === c.value ? `2px solid #2563eb` : '2px solid transparent',
|
|
outlineOffset: 3, transition: 'all 0.12s',
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ width: 1, height: 20, background: '#e2e8f0' }} />
|
|
|
|
{/* Thickness */}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
<span style={{ fontSize: 11, fontWeight: 700, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.06em' }}>Thickness</span>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
{THICKNESS_OPTIONS.map((o) => (
|
|
<button
|
|
key={o.value}
|
|
title={o.label}
|
|
onClick={() => setThickness(o.value)}
|
|
style={{
|
|
width: 32, height: 30, border: 'none', borderRadius: 6, cursor: 'pointer',
|
|
background: thickness === o.value ? '#eff6ff' : '#ffffff',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
boxShadow: thickness === o.value ? 'inset 0 0 0 1.5px #2563eb' : 'inset 0 0 0 1px #e2e8f0',
|
|
transition: 'all 0.12s',
|
|
}}
|
|
>
|
|
<div style={{
|
|
width: 18, height: o.value * 0.9,
|
|
borderRadius: 99,
|
|
background: thickness === o.value ? '#2563eb' : '#64748b',
|
|
transition: 'all 0.12s',
|
|
}} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Clear */}
|
|
<CustomButton variant="outline" size="sm" onClick={clearCanvas}>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M3 6h18M19 6l-1 14H6L5 6M10 11v6M14 11v6M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/></svg>
|
|
Clear
|
|
</CustomButton>
|
|
</div>
|
|
|
|
{/* Canvas */}
|
|
<div style={{ position: 'relative', borderRadius: 12, overflow: 'hidden', boxShadow: '0 0 0 1.5px #e2e8f0, 0 4px 20px rgba(0,0,0,0.05)' }}>
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={CANVAS_W}
|
|
height={CANVAS_H}
|
|
onPointerDown={onDown}
|
|
onPointerMove={onMove}
|
|
onPointerUp={onUp}
|
|
onPointerCancel={onUp}
|
|
style={{
|
|
width: '100%', height: 200,
|
|
display: 'block', cursor: 'crosshair',
|
|
background: '#f8fafc',
|
|
touchAction: 'none',
|
|
}}
|
|
/>
|
|
{!hasInk.current && strokes.current.length === 0 && (
|
|
<div style={{
|
|
position: 'absolute', inset: 0, pointerEvents: 'none',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
flexDirection: 'column', gap: 6,
|
|
}}>
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#c7d2e0" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
|
|
</svg>
|
|
<p style={{ fontSize: 12, color: '#b8c5d6', fontWeight: 500, margin: 0 }}>Sign here</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p style={{ fontSize: 11, color: '#94a3b8', marginTop: 8, textAlign: 'center' }}>
|
|
Use mouse, stylus, or finger — pressure sensitivity supported
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* ══ TYPE ══ */}
|
|
{mode === 'type' && (
|
|
<div>
|
|
{/* Name input */}
|
|
<input
|
|
autoFocus
|
|
value={typed}
|
|
onChange={(e) => setTyped(e.target.value)}
|
|
placeholder="Type your full name"
|
|
maxLength={60}
|
|
style={{
|
|
width: '100%', boxSizing: 'border-box',
|
|
padding: '11px 14px', borderRadius: 9,
|
|
border: '1.5px solid #e2e8f0', background: '#f8fafc',
|
|
fontSize: 14, fontWeight: 500, color: '#0f172a', outline: 'none',
|
|
transition: 'border-color 0.15s',
|
|
}}
|
|
onFocus={(e) => (e.currentTarget.style.borderColor = '#2563eb')}
|
|
onBlur={(e) => (e.currentTarget.style.borderColor = '#e2e8f0')}
|
|
/>
|
|
|
|
{/* Font picker */}
|
|
<div style={{ marginTop: 14 }}>
|
|
<p style={{ fontSize: 11, fontWeight: 600, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>
|
|
Style
|
|
</p>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
|
{SIGNATURE_FONTS.map((f, i) => (
|
|
<button
|
|
key={f.family}
|
|
onClick={() => setFontIdx(i)}
|
|
style={{
|
|
padding: '10px 14px', borderRadius: 9,
|
|
border: fontIdx === i ? '1.5px solid #2563eb' : '1.5px solid #e2e8f0',
|
|
background: fontIdx === i ? '#eff6ff' : '#f8fafc',
|
|
cursor: 'pointer', textAlign: 'left', transition: 'all 0.15s',
|
|
display: 'flex', flexDirection: 'column', gap: 3,
|
|
}}
|
|
>
|
|
<span style={{ fontSize: 10, fontWeight: 600, color: fontIdx === i ? '#2563eb' : '#94a3b8', letterSpacing: '0.04em', textTransform: 'uppercase' }}>
|
|
{f.label}
|
|
</span>
|
|
<span style={{
|
|
fontFamily: f.css, fontSize: 26, color: typeColor,
|
|
lineHeight: 1.2, display: 'block', maxWidth: '100%',
|
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
}}>
|
|
{typed.trim() || 'Preview'}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Ink color */}
|
|
<div style={{ marginTop: 20, display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
<span style={{ fontSize: 11, fontWeight: 700, color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.04em' }}>Color</span>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
{INK_COLORS.map((c) => (
|
|
<button
|
|
key={c.value}
|
|
title={c.label}
|
|
onClick={() => setTypeColor(c.value)}
|
|
style={{
|
|
width: 24, height: 24, borderRadius: '50%',
|
|
background: c.value, border: 'none', cursor: 'pointer',
|
|
outline: typeColor === c.value ? `2px solid #2563eb` : '2px solid transparent',
|
|
outlineOffset: 3, transition: 'all 0.12s',
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Live preview */}
|
|
<div style={{
|
|
marginTop: 16, height: 90, borderRadius: 10,
|
|
background: 'linear-gradient(180deg,#f8faff 0%,#eef2fb 100%)',
|
|
border: '1.5px dashed #c7d7f5',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden',
|
|
}}>
|
|
<span style={{
|
|
fontFamily: SIGNATURE_FONTS[fontIdx].css,
|
|
fontSize: 54, color: typeColor, lineHeight: 1,
|
|
maxWidth: '90%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
}}>
|
|
{typed.trim() || 'Your Signature'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ══ UPLOAD ══ */}
|
|
{mode === 'upload' && (
|
|
<div>
|
|
<label
|
|
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
|
onDragLeave={() => setDragging(false)}
|
|
onDrop={handleDrop}
|
|
style={{
|
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
|
minHeight: 220, borderRadius: 12, cursor: 'pointer',
|
|
border: `2px dashed ${dragging ? '#2563eb' : uploaded ? '#c7d7f5' : '#cbd5e1'}`,
|
|
background: dragging ? '#eff6ff' : uploaded ? '#f8faff' : '#f8fafc',
|
|
transition: 'all 0.18s', gap: 10,
|
|
}}
|
|
>
|
|
{uploaded ? (
|
|
<>
|
|
<img
|
|
src={uploaded.url}
|
|
alt="signature preview"
|
|
style={{ maxHeight: 160, maxWidth: '85%', objectFit: 'contain', borderRadius: 6 }}
|
|
/>
|
|
<span style={{ fontSize: 12, color: '#2563eb', fontWeight: 600, marginTop: 4 }}>
|
|
Click to replace
|
|
</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div style={{
|
|
width: 52, height: 52, borderRadius: 14,
|
|
background: 'linear-gradient(135deg,#eff6ff,#e0e7ff)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}}>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#2563eb" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="16 16 12 12 8 16"/><line x1="12" y1="12" x2="12" y2="21"/>
|
|
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"/>
|
|
</svg>
|
|
</div>
|
|
<div style={{ textAlign: 'center' }}>
|
|
<p style={{ fontSize: 13.5, fontWeight: 700, color: '#0f172a', margin: 0 }}>
|
|
Drop image here or click to browse
|
|
</p>
|
|
<p style={{ fontSize: 11, color: '#94a3b8', margin: '4px 0 0' }}>
|
|
PNG with transparent background gives the best result
|
|
</p>
|
|
</div>
|
|
<span style={{
|
|
fontSize: 11, fontWeight: 600, color: '#2563eb',
|
|
padding: '6px 14px', borderRadius: 7, background: '#eff6ff',
|
|
border: '1px solid #bfdbfe',
|
|
}}>
|
|
Choose file
|
|
</span>
|
|
</>
|
|
)}
|
|
<input type="file" accept="image/*" onChange={handleFileInput} style={{ display: 'none' }} />
|
|
</label>
|
|
|
|
{uploaded && (
|
|
<button
|
|
onClick={() => setUploaded(null)}
|
|
style={{
|
|
marginTop: 10, width: '100%', padding: '7px 0', borderRadius: 8,
|
|
border: '1px solid #fee2e2', background: '#fff5f5', cursor: 'pointer',
|
|
fontSize: 12, fontWeight: 600, color: '#dc2626',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
|
transition: 'all 0.15s',
|
|
}}
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M3 6h18M19 6l-1 14H6L5 6M10 11v6M14 11v6M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/></svg>
|
|
Remove image
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ══ SAVED ══ */}
|
|
{mode === 'saved' && (
|
|
<div>
|
|
{savedSigs.length === 0 ? (
|
|
<div style={{
|
|
minHeight: 200, display: 'flex', flexDirection: 'column',
|
|
alignItems: 'center', justifyContent: 'center', gap: 10,
|
|
}}>
|
|
<div style={{
|
|
width: 52, height: 52, borderRadius: 14, background: '#f1f5f9',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}}>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
|
|
</svg>
|
|
</div>
|
|
<p style={{ fontSize: 13.5, fontWeight: 700, color: '#0f172a', margin: 0 }}>No saved signatures</p>
|
|
<p style={{ fontSize: 12, color: '#94a3b8', margin: 0 }}>Your signatures will appear here after use</p>
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{savedSigs.map((s) => (
|
|
<div
|
|
key={s.id}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 12,
|
|
padding: '10px 12px', borderRadius: 10,
|
|
border: '1.5px solid #edf0f5', background: '#f8fafc',
|
|
transition: 'border-color 0.15s',
|
|
}}
|
|
onMouseEnter={(e) => ((e.currentTarget as HTMLDivElement).style.borderColor = '#bfdbfe')}
|
|
onMouseLeave={(e) => ((e.currentTarget as HTMLDivElement).style.borderColor = '#edf0f5')}
|
|
>
|
|
{/* Thumbnail */}
|
|
<div style={{
|
|
width: 120, height: 52, borderRadius: 8, overflow: 'hidden',
|
|
background: '#fff', border: '1px solid #e2e8f0',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
flexShrink: 0,
|
|
}}>
|
|
<img
|
|
src={s.dataUrl}
|
|
alt={s.label}
|
|
style={{ maxWidth: '95%', maxHeight: '90%', objectFit: 'contain' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<p style={{ fontSize: 13, fontWeight: 600, color: '#0f172a', margin: 0,
|
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{s.label}
|
|
</p>
|
|
<p style={{ fontSize: 11, color: '#94a3b8', margin: '2px 0 0' }}>
|
|
{new Date(s.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
|
<CustomButton
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => handleUseSaved(s)}
|
|
>
|
|
Use
|
|
</CustomButton>
|
|
<CustomButton
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleDeleteSaved(s.id)}
|
|
title="Delete"
|
|
style={{ padding: '0 8px' }}
|
|
>
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M3 6h18M19 6l-1 14H6L5 6M10 11v6M14 11v6M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/></svg>
|
|
</CustomButton>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Footer ── */}
|
|
{mode !== 'saved' && (
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
padding: '14px 24px', borderTop: '1px solid #edf0f5',
|
|
background: '#f8fafc',
|
|
}}>
|
|
<p style={{ fontSize: 11, color: '#94a3b8', margin: 0 }}>
|
|
⚡ Visual signature — placed as image overlay
|
|
</p>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<CustomButton variant="outline" onClick={handleClose}>Cancel</CustomButton>
|
|
<CustomButton
|
|
variant="primary"
|
|
onClick={handleConfirm}
|
|
style={{ display: 'flex', alignItems: 'center', gap: 7 }}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
Use Signature
|
|
</CustomButton>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Keyframe animation */}
|
|
<style>{`
|
|
@keyframes sigModalIn {
|
|
from { opacity: 0; transform: scale(0.94) translateY(12px); }
|
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|