done the signature module
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
# DocQube (PDF Engine) Feature Inventory
|
||||
|
||||
## STEP 1 — SCOPE
|
||||
- **Product Name:** DocQube (Internal project name: "PDF Engine")
|
||||
- **Tech Stack:**
|
||||
- **Frontend:** React (TSX), Vite, Tailwind CSS, custom PDF viewer components (no off-the-shelf PDF.js renderer wrapper).
|
||||
- **Backend:** FastAPI (Python), interacting with a custom C++ PDF engine via Python bindings (WASM/C++ bridge).
|
||||
- **Structure:** Monorepo containing a `frontend` and `gateway` directory. Auditing the entire cohesive product (the web-based PDF editor).
|
||||
|
||||
## STEP 2 & 3 — VERIFIED INVENTORY
|
||||
|
||||
| Feature Name | What it does | Build Status | Evidence | Demoable end-to-end? | Gating / dependencies | Docs mismatch | Assessment confidence |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **Document Upload & Open** | Allows users to upload a local PDF file into the web editor. | LIVE | `App.tsx` (`handleUpload`), `crud.py` (`upload_document`) | Yes | Requires backend engine | None | High, core flow fully wired. |
|
||||
| **Password Protected PDFs** | Prompts for a password when attempting to open an encrypted PDF document. | LIVE | `PasswordModal.tsx`, `App.tsx` (`passwordPrompt`), `crud.py` catches "Password required" | Yes | None | None | High, error handling and UI modal exist. |
|
||||
| **Document Export / Save** | Downloads the modified PDF document back to the user's device. | LIVE | `App.tsx` (`handleExport`), `edits.py` (`save_full` / `save_incremental`) | Yes | None | None | High, backend saving mechanisms are implemented. |
|
||||
| **Print Document** | Prints the current state of the document using the browser's native print dialog. | LIVE | `App.tsx` (`handlePrint`), creates hidden iframe | Yes | None | None | High, standard browser API implementation. |
|
||||
| **Text Search** | Searches the document for specific text (supports case-sensitive and whole-word modes). | LIVE | `App.tsx` (`searchQuery`), `content.py` (`search_document`), `SearchOverlayLayer.tsx` | Yes | None | None | High, search API and UI overlay implemented. |
|
||||
| **Undo / Redo** | Reverts or reapplies recent edits and annotations. | LIVE | `App.tsx` (`hist.stack`), relies on immutable document IDs | Yes | None | None | High, tracks document versions gracefully. |
|
||||
| **Page Rotation** | Rotates a specific page by 90 degrees. | LIVE | `App.tsx` (`handleRotate`), `edits.py` (`PageRotationOperation`) | Yes | Requires `canAssemble` permission | None | High. |
|
||||
| **Page Deletion** | Removes a specific page from the document. | LIVE | `App.tsx` (`handleDeletePage`), `edits.py` (`PageDeletionOperation`) | Yes | Requires `canAssemble` permission | None | High. |
|
||||
| **Page Reordering** | Moves a page to a new index within the document. | LIVE | `App.tsx` (`handleReorderPage`), `edits.py` (`PageReorderOperation`) | Yes | Requires `canAssemble` permission | None | High. |
|
||||
| **Highlight Annotation** | Draws a colored, semi-transparent highlight over selected text. | LIVE | `PDFViewer.tsx` (`handleTextSelection`), `edits.py` (`HighlightOperation`) | Yes | Requires `canAnnotate` permission | None | High. |
|
||||
| **Text Decorations** | Applies underline, strikeout, or squiggly lines to selected text. | LIVE | `App.tsx` (`handleDecorateText`), `edits.py` (`UnderlineOperation` etc.) | Yes | Requires `canAnnotate` permission | None | High. |
|
||||
| **Freehand Drawing (Ink)** | Allows freehand drawing with adjustable color and thickness. | LIVE | `Toolbar.tsx` (draw tool), `edits.py` (`FreehandOperation`) | Yes | Requires `canAnnotate` permission | None | High. |
|
||||
| **Sticky Notes / Comments** | Places a clickable comment icon with an author and text content. | LIVE | `App.tsx` (`handleAnnotationAdded`), `edits.py` (`CommentOperation`) | Yes | Requires `canAnnotate` permission | None | High. |
|
||||
| **Text Box (Free Text)** | Overlays custom text with adjustable font size and color anywhere on the page. | LIVE | `App.tsx` (`handlePlaceText`), `edits.py` (`TextOverlayOperation`) | Yes | Requires `canAnnotate` permission | None | High. |
|
||||
| **Stamps** | Places pre-configured text stamps (e.g., "APPROVED") with bold styling. | LIVE | `Toolbar.tsx` (`STAMP_PRESETS`), `App.tsx` (`handlePlaceStamp`) | Yes | Requires `canAnnotate` permission | None | High, reuses TextOverlay under the hood. |
|
||||
| **Visual Signatures** | Allows users to create (draw, type, upload) and place a visual signature. | LIVE | `SignatureModal.tsx`, `App.tsx` (`handlePlaceSignature`), `edits.py` (`ImageOverlayOperation`) | Yes | Requires `canAnnotate` permission | None | High, includes base64 image processing in backend. |
|
||||
| **Permanent Redaction** | Blacks out areas and permanently removes underlying text, images, and vectors. | LIVE | `App.tsx` (`handleMarkRedaction`, `handleApplyRedactions`), `edits.py` (`RedactionOperation`) | Yes | Requires `canModify` permission | None | High, recently updated to support hover preview and batch apply. |
|
||||
| **Form Filling** | Allows users to fill out interactive PDF form fields. | LIVE | `PDFViewer.tsx` (`onFieldChange`), `edits.py` (`UpdateFieldOperation`) | Yes | Requires `canFillForms` permission | None | High. |
|
||||
| **Paragraph Text Editing** | Click to edit existing PDF text with automatic paragraph reflow and justification. | LIVE | `Toolbar.tsx` (`edit_text`), `edits.py` (`ReflowParagraphOperation` / `ReplaceTextOperation`) | Yes | Requires `canModify` permission | None | High, complex reflow logic wired to backend. |
|
||||
| **Stream / Raw Text Edit** | Advanced byte-level surgical text editing without reflow. | IN-DEV | `Toolbar.tsx` (labeled as "Beta"), `StreamEditLayer.tsx` | Yes | None | None | High, explicitly marked as Beta in the UI. |
|
||||
| **Document Inspector** | Side panel showing pages, outline/bookmarks, annotations list, document metadata, and fonts. | LIVE | `InspectorPanel.tsx`, `content.py` (`get_document_annotations`, `get_page_model`) | Yes | None | None | High. |
|
||||
|
||||
|
||||
### A) Docs/marketing claims NOT supported by code
|
||||
*No significant `README.md` marketing claims were evaluated since none were present in the immediate workspace, but within the code, the "Signature" tool clearly flags itself as "Visual signature — not certified" (`Toolbar.tsx:145`). If marketing claims "digital signatures" or "cryptographic signing", the code does not support it.*
|
||||
|
||||
### B) Open questions for the team
|
||||
1. **Stream Edit (Beta):** The `stream_edit` tool is explicitly marked as beta for "surgical byte-level edit". Is this intended to be promoted to sales, or kept as a power-user/developer toggle?
|
||||
2. **Signature Storage:** Signatures are maintained in local state (`pendingSignature`). If a user refreshes the page, they must recreate their signature. Are there plans to persist visual signatures to a user profile?
|
||||
3. **Collaboration:** The architecture passes an `author` string to annotations, but there is no apparent multiplayer or auth/login system wired up in the immediate frontend logic (the default author is hardcoded to `'Current User'` in `PDFViewer.tsx:325`). Does sales intend to sell this as a collaborative tool?
|
||||
@@ -329,15 +329,12 @@ function App() {
|
||||
}], `Stamp “${activeStamp.label}” placed`);
|
||||
};
|
||||
|
||||
const handlePlaceSignature = (pageIndex: number, point: { x: number; y: number }) => {
|
||||
const handlePlaceSignature = (pageIndex: number, pdfRect: { x: number; y: number; width: number; height: number }, _rotation: number) => {
|
||||
if (!pendingSignature) return;
|
||||
if (!can('canAnnotate')) { denyToast('Signing'); setActiveTool('select'); return; }
|
||||
const width = 160;
|
||||
const height = width / (pendingSignature.aspect || 3);
|
||||
const pdf = viewportRectToPdf({ x: point.x, y: point.y, width, height }, 1, pageHeightPts(pageIndex));
|
||||
applyOps([{
|
||||
id: rid('sig'), type: 'image_overlay', pageIndex,
|
||||
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, imageData: pendingSignature.url },
|
||||
data: { x: pdfRect.x, y: pdfRect.y, width: pdfRect.width, height: pdfRect.height, imageData: pendingSignature.url },
|
||||
}], 'Signature placed');
|
||||
setActiveTool('select');
|
||||
};
|
||||
@@ -578,6 +575,8 @@ function App() {
|
||||
activeTool={activeTool}
|
||||
toolSettings={toolSettings}
|
||||
hasSignature={!!pendingSignature}
|
||||
signatureImageUrl={pendingSignature?.url}
|
||||
signatureAspect={pendingSignature?.aspect}
|
||||
activeStamp={activeStamp?.label ?? null}
|
||||
annotations={annotations}
|
||||
canCopy={can('canCopy')}
|
||||
|
||||
@@ -1,82 +1,276 @@
|
||||
import { CustomButton } from './custom/CustomButton';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Modal } from './ui';
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { toast } from '../lib/toast';
|
||||
|
||||
/* ─── Types ─────────────────────────────────────────────── */
|
||||
interface SignatureModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (dataUrl: string, aspect: number) => void;
|
||||
}
|
||||
|
||||
type Mode = 'draw' | 'type' | 'upload';
|
||||
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');
|
||||
const [typed, setTyped] = useState('');
|
||||
const [uploaded, setUploaded] = useState<{ url: string; aspect: number } | null>(null);
|
||||
|
||||
/* 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);
|
||||
|
||||
const resetLocal = () => { setMode('draw'); setTyped(''); setUploaded(null); hasInk.current = false; };
|
||||
const handleClose = () => { resetLocal(); onClose(); };
|
||||
/* type */
|
||||
const [typed, setTyped] = useState('');
|
||||
const [fontIdx, setFontIdx] = useState(0);
|
||||
const [typeColor, setTypeColor] = useState(INK_COLORS[0].value);
|
||||
|
||||
const clearCanvas = () => {
|
||||
/* 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);
|
||||
};
|
||||
|
||||
const pos = (e: React.PointerEvent) => {
|
||||
/* ── 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) };
|
||||
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 ctx = canvasRef.current!.getContext('2d')!;
|
||||
const { x, y } = pos(e);
|
||||
ctx.beginPath(); ctx.moveTo(x, y);
|
||||
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 { x, y } = pos(e);
|
||||
ctx.lineTo(x, y); ctx.strokeStyle = '#1b2430'; ctx.lineWidth = 2.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke();
|
||||
const stroke = currentStroke.current;
|
||||
strokePoints(ctx, stroke.slice(-3), inkColor, thickness);
|
||||
hasInk.current = true;
|
||||
};
|
||||
const onUp = () => { drawing.current = false; };
|
||||
|
||||
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) { toast('Draw a signature first', 'error'); return; }
|
||||
if (!hasInk.current) { toast('Draw your signature first', 'error'); return; }
|
||||
const c = canvasRef.current!;
|
||||
onConfirm(c.toDataURL('image/png'), c.width / c.height);
|
||||
dataUrl = c.toDataURL('image/png');
|
||||
aspect = c.width / c.height;
|
||||
} else if (mode === 'type') {
|
||||
if (!typed.trim()) { toast('Type your name first', 'error'); return; }
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 600; c.height = 200;
|
||||
const ctx = c.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
ctx.fillStyle = '#1b2430';
|
||||
ctx.font = 'italic 88px "Brush Script MT", "Segoe Script", cursive';
|
||||
ctx.textBaseline = 'middle'; ctx.textAlign = 'center';
|
||||
ctx.fillText(typed.trim(), c.width / 2, c.height / 2);
|
||||
onConfirm(c.toDataURL('image/png'), c.width / c.height);
|
||||
const res = buildTypedCanvas();
|
||||
dataUrl = res.dataUrl; aspect = res.aspect;
|
||||
} else if (mode === 'upload') {
|
||||
if (!uploaded) { toast('Upload an image first', 'error'); return; }
|
||||
onConfirm(uploaded.url, uploaded.aspect);
|
||||
if (!uploaded) { toast('Upload a signature image first', 'error'); return; }
|
||||
dataUrl = uploaded.url; aspect = uploaded.aspect;
|
||||
} else if (mode === 'saved') {
|
||||
toast('Click "Use" on a saved signature', 'error'); return;
|
||||
}
|
||||
|
||||
saveToStorage(dataUrl, aspect);
|
||||
onConfirm(dataUrl, aspect);
|
||||
resetLocal();
|
||||
};
|
||||
|
||||
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
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;
|
||||
@@ -85,85 +279,544 @@ export const SignatureModal: React.FC<SignatureModalProps> = ({ open, onClose, o
|
||||
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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title="Create signature"
|
||||
width={580}
|
||||
footer={
|
||||
<>
|
||||
<CustomButton variant="outline" onClick={handleClose}>Cancel</CustomButton>
|
||||
<CustomButton variant="primary" onClick={handleConfirm}>Use signature</CustomButton>
|
||||
</>
|
||||
}
|
||||
<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="mb-5 flex gap-1.5 rounded-[8px] bg-[#edeff2] p-1.5">
|
||||
{(['draw', 'type', 'upload'] as Mode[]).map((m) => (
|
||||
<CustomButton variant="unstyled" key={m} onClick={() => setMode(m)}
|
||||
className={`flex-1 rounded-[6px] py-2 text-[13px] font-semibold capitalize transition-colors ${mode === m ? 'bg-[#ffffff] text-[#2563eb] shadow-sm' : 'text-[#5b6573] hover:text-[#18212e]'}`}>
|
||||
{m}
|
||||
</CustomButton>
|
||||
))}
|
||||
<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>
|
||||
|
||||
{mode === 'draw' && (
|
||||
<div>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={920}
|
||||
height={300}
|
||||
onPointerDown={onDown}
|
||||
onPointerMove={onMove}
|
||||
onPointerUp={onUp}
|
||||
onPointerCancel={onUp}
|
||||
className="h-[200px] w-full touch-none rounded-[8px] border border-dashed border-[#dadde2] bg-[#f6f7f9]"
|
||||
style={{ cursor: 'crosshair' }}
|
||||
/>
|
||||
<div className="mt-2 flex justify-between">
|
||||
<span className="text-[11px] text-[#98a1ad]">Draw your signature above</span>
|
||||
<CustomButton variant="unstyled" className="text-[12px] font-semibold text-[#2563eb] hover:underline" onClick={clearCanvas}>Clear</CustomButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'type' && (
|
||||
<div>
|
||||
<input
|
||||
autoFocus
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
placeholder="Type your name"
|
||||
className="w-full rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9] px-3 py-2.5 text-[14px] outline-none focus:border-[#2563eb]"
|
||||
/>
|
||||
<div className="mt-3 flex h-[120px] items-center justify-center rounded-[8px] border border-[#ebedf0] bg-[#f6f7f9]">
|
||||
<span style={{ fontFamily: '"Brush Script MT","Segoe Script",cursive', fontStyle: 'italic', fontSize: 52, color: '#18212e' }}>
|
||||
{typed || 'Preview'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'upload' && (
|
||||
<div>
|
||||
<label className="flex h-[160px] cursor-pointer flex-col items-center justify-center gap-2 rounded-[8px] border border-dashed border-[#dadde2] bg-[#f6f7f9] text-[#5b6573] hover:border-[#2563eb]">
|
||||
{uploaded ? (
|
||||
<img src={uploaded.url} alt="signature" className="max-h-[130px] max-w-[90%] object-contain" />
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[13px] font-semibold">Click to upload an image</span>
|
||||
<span className="text-[11px] text-[#98a1ad]">PNG with transparent background works best</span>
|
||||
</>
|
||||
)}
|
||||
<input type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-[11px] text-[#98a1ad]">Visual signature only — not a certified e-signature.</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,22 +9,23 @@ import {
|
||||
UnderlineIcon, StrikeoutIcon, SquigglyIcon
|
||||
} from './icons';
|
||||
|
||||
interface ToolDef { id: ToolId; label: string; shortcut: string; icon: React.ReactNode; danger?: boolean }
|
||||
interface ToolDef { id: ToolId; label: string; shortLabel?: string; shortcut: string; icon: React.ReactNode; danger?: boolean }
|
||||
|
||||
const TOOLS: (ToolDef | 'divider')[] = [
|
||||
{ id: 'select', label: 'Select & copy text', shortcut: 'V', icon: <SelectIcon /> },
|
||||
{ id: 'select', label: 'Select & copy text', shortLabel: 'Select', shortcut: 'V', icon: <SelectIcon /> },
|
||||
{ id: 'pan', label: 'Pan', shortcut: 'H', icon: <PanIcon /> },
|
||||
'divider',
|
||||
{ id: 'highlight', label: 'Highlight', shortcut: 'K', icon: <HighlightIcon /> },
|
||||
{ id: 'underline', label: 'Underline', shortcut: 'U', icon: <UnderlineIcon /> },
|
||||
{ id: 'strikeout', label: 'Strikeout', shortcut: 'X', icon: <StrikeoutIcon /> },
|
||||
{ id: 'squiggly', label: 'Squiggly', shortcut: 'W', icon: <SquigglyIcon /> },
|
||||
{ id: 'draw', label: 'Draw (ink)', shortcut: 'D', icon: <DrawIcon /> },
|
||||
{ id: 'draw', label: 'Draw (ink)', shortLabel: 'Draw', shortcut: 'D', icon: <DrawIcon /> },
|
||||
{ id: 'comment', label: 'Comment', shortcut: 'C', icon: <CommentIcon /> },
|
||||
{ id: 'textbox', label: 'Text box', shortcut: 'T', icon: <TextBoxIcon /> },
|
||||
{ id: 'textbox', label: 'Text box', shortLabel: 'Text', shortcut: 'T', icon: <TextBoxIcon /> },
|
||||
{
|
||||
id: 'edit_text',
|
||||
label: 'Edit text',
|
||||
shortLabel: 'Edit',
|
||||
shortcut: 'E',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -36,6 +37,7 @@ const TOOLS: (ToolDef | 'divider')[] = [
|
||||
{
|
||||
id: 'stream_edit',
|
||||
label: 'Raw Text (beta)',
|
||||
shortLabel: 'Raw',
|
||||
shortcut: 'Q',
|
||||
icon: (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -66,7 +68,7 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; on
|
||||
aria-pressed={active}
|
||||
aria-disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={`relative flex h-11 w-11 items-center justify-center rounded-[10px] transition-colors ${
|
||||
className={`relative flex h-[52px] w-[72px] shrink-0 flex-col items-center justify-center gap-[3px] rounded-[10px] transition-colors ${
|
||||
disabled
|
||||
? 'cursor-not-allowed text-[#c5cad1]'
|
||||
: active
|
||||
@@ -75,8 +77,9 @@ const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; on
|
||||
: 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'
|
||||
}`}
|
||||
>
|
||||
{active && !disabled && <span className="absolute left-[-10px] h-5 w-[3px] rounded-full" style={{ background: t.danger ? '#dc2626' : '#2563eb' }} />}
|
||||
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 22 }) : t.icon}
|
||||
{active && !disabled && <span className="absolute left-[0px] h-5 w-[3px] rounded-r-full" style={{ background: t.danger ? '#dc2626' : '#2563eb' }} />}
|
||||
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 20 }) : t.icon}
|
||||
<span className="text-[10px] font-medium leading-none tracking-tight">{t.shortLabel || t.label}</span>
|
||||
</CustomButton>
|
||||
);
|
||||
|
||||
@@ -91,16 +94,16 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="flex h-full shrink-0 flex-col items-center gap-1.5 border-r border-[#ebedf0] bg-[#ffffff]" style={{ width: '80px', paddingTop: '14px', paddingBottom: '12px' }}>
|
||||
<nav className="flex h-full shrink-0 flex-col items-center gap-2 overflow-y-auto border-r border-[#ebedf0] bg-[#ffffff] scroll-micro" style={{ width: '92px', paddingTop: '16px', paddingBottom: '16px' }}>
|
||||
{TOOLS.map((t, i) =>
|
||||
t === 'divider'
|
||||
? <div key={`d${i}`} className="my-0.5 h-px w-6 bg-[#ebedf0]" />
|
||||
? <div key={`d${i}`} className="my-1 h-px w-10 shrink-0 bg-[#ebedf0]" />
|
||||
: <RailButton key={t.id} t={t} active={activeTool === t.id} disabled={disabledTools?.has(t.id)} onClick={() => pickTool(t.id)} />,
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
<div className="flex-1 shrink-0 min-h-[16px]" />
|
||||
|
||||
<CustomButton variant="unstyled" title="About Maskan PDF Editor" onClick={onOpenAbout} className="flex h-9 w-9 items-center justify-center rounded-[10px] text-[#98a1ad] transition-colors hover:bg-[#edeff2] hover:text-[#18212e]">
|
||||
<CustomButton variant="unstyled" title="About Maskan PDF Editor" onClick={onOpenAbout} className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] text-[#98a1ad] transition-colors hover:bg-[#edeff2] hover:text-[#18212e]">
|
||||
<InfoIcon size={19} />
|
||||
</CustomButton>
|
||||
|
||||
@@ -108,7 +111,7 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
|
||||
align="left"
|
||||
width={210}
|
||||
trigger={(open) => (
|
||||
<CustomButton variant="unstyled" title="Keyboard shortcuts" className={`flex h-9 w-9 items-center justify-center rounded-[10px] transition-colors ${open ? 'bg-[#edeff2] text-[#18212e]' : 'text-[#98a1ad] hover:bg-[#edeff2] hover:text-[#18212e]'}`}>
|
||||
<CustomButton variant="unstyled" title="Keyboard shortcuts" className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] transition-colors ${open ? 'bg-[#edeff2] text-[#18212e]' : 'text-[#98a1ad] hover:bg-[#edeff2] hover:text-[#18212e]'}`}>
|
||||
<HelpIcon size={19} />
|
||||
</CustomButton>
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
.scrollbar-none { scrollbar-width: none; -ms-overflow-style: none; }
|
||||
.scrollbar-none::-webkit-scrollbar { width: 0; height: 0; display: none; }
|
||||
|
||||
/* Micro scrollbar (used by narrow sidebars) */
|
||||
.scroll-micro { scrollbar-width: thin; }
|
||||
.scroll-micro::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
.scroll-micro::-webkit-scrollbar-track { background: transparent; }
|
||||
.scroll-micro::-webkit-scrollbar-thumb { background: #dadde2; border-radius: 9999px; }
|
||||
.scroll-micro::-webkit-scrollbar-thumb:hover { background: #98a1ad; }
|
||||
|
||||
/* Animations */
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||
|
||||
@@ -18,7 +18,8 @@ interface OverlayLayerProps {
|
||||
onAnnotationAdded?: (anno: Annotation) => void;
|
||||
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
|
||||
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
|
||||
onPlaceSignature?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
|
||||
/** Called when user clicks to begin interactive signature placement */
|
||||
onBeginPlacement?: (pageIndex: number, viewportPt: { x: number; y: number }) => void;
|
||||
}
|
||||
|
||||
const TEXTBOX_WIDTH_PTS = 200;
|
||||
@@ -28,7 +29,7 @@ const POINTER_TOOLS = ['draw', 'comment', 'textbox', 'stamp', 'signature'];
|
||||
export const OverlayLayer: React.FC<OverlayLayerProps> = ({
|
||||
pageIndex, width, height, activeTool, zoom,
|
||||
inkColor, inkThickness, textColor, fontSize, hasSignature, activeStamp,
|
||||
onAnnotationAdded, onPlaceText, onPlaceStamp, onPlaceSignature,
|
||||
onAnnotationAdded, onPlaceText, onPlaceStamp, onBeginPlacement,
|
||||
}) => {
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [currentPath, setCurrentPath] = useState<{ x: number; y: number }[]>([]);
|
||||
@@ -88,7 +89,14 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
|
||||
} else if (activeTool === 'stamp' && activeStamp) {
|
||||
onPlaceStamp?.(pageIndex, coords);
|
||||
} else if (activeTool === 'signature' && hasSignature) {
|
||||
onPlaceSignature?.(pageIndex, coords);
|
||||
// Pass viewport coordinates (relative to page) to begin interactive placement
|
||||
const el = rootRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
onBeginPlacement?.(pageIndex, {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import { toast } from '../lib/toast';
|
||||
import { RedactionLayer } from './RedactionLayer';
|
||||
import { StreamEditLayer } from './StreamEditLayer';
|
||||
import { wasmFreeDocument } from '../lib/pdfiumEngine';
|
||||
import { SignaturePlacementOverlay } from './SignaturePlacementOverlay';
|
||||
import type { PlacementRect } from './SignaturePlacementOverlay';
|
||||
|
||||
interface PDFViewerProps {
|
||||
documentId: string;
|
||||
@@ -27,6 +29,9 @@ interface PDFViewerProps {
|
||||
activeTool: string;
|
||||
toolSettings: ToolSettings;
|
||||
hasSignature: boolean;
|
||||
/** The data-URL of the pending signature image (for the placement preview) */
|
||||
signatureImageUrl?: string;
|
||||
signatureAspect?: number;
|
||||
activeStamp: string | null;
|
||||
annotations: Annotation[];
|
||||
searchQuery?: string;
|
||||
@@ -44,7 +49,8 @@ interface PDFViewerProps {
|
||||
onReflowParagraph?: (pageIndex: number, payload: ReflowParagraphPayload) => void;
|
||||
onStreamDocumentChanged?: (newDocumentId: string) => void;
|
||||
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
|
||||
onPlaceSignature?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
|
||||
/** Called with final PDF-space rect after user commits interactive placement */
|
||||
onPlaceSignature?: (pageIndex: number, pdfRect: Rect, rotation: number) => void;
|
||||
onDecorateText?: (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => void;
|
||||
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
|
||||
canCopy?: boolean;
|
||||
@@ -73,6 +79,8 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
activeTool,
|
||||
toolSettings,
|
||||
hasSignature,
|
||||
signatureImageUrl,
|
||||
signatureAspect = 3,
|
||||
activeStamp,
|
||||
annotations,
|
||||
searchQuery,
|
||||
@@ -104,6 +112,13 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
const prevDocumentIdRef = useRef<string>(documentId);
|
||||
const inFlightRenderRef = useRef<Set<number>>(new Set());
|
||||
const inFlightTextRef = useRef<Set<number>>(new Set());
|
||||
|
||||
/** Active interactive signature placement state */
|
||||
const [activePlacement, setActivePlacement] = useState<{
|
||||
pageIndex: number;
|
||||
rect: PlacementRect;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPageTexts({});
|
||||
const prev = prevDocumentIdRef.current;
|
||||
@@ -494,7 +509,20 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
onAnnotationAdded={onAnnotationAdded}
|
||||
onPlaceText={onPlaceText}
|
||||
onPlaceStamp={onPlaceStamp}
|
||||
onPlaceSignature={onPlaceSignature}
|
||||
onBeginPlacement={(pageIdx, viewportPt) => {
|
||||
// Compute a sensible initial rect centred on the click point
|
||||
const pageInfo = pagesInfo?.[pageIdx];
|
||||
const pgW = pageInfo ? pageInfo.width * zoom : page.width;
|
||||
const pgH = pageInfo ? pageInfo.height * zoom : page.height;
|
||||
const sigW = Math.min(180 * zoom, pgW * 0.5);
|
||||
const sigH = sigW / (signatureAspect || 3);
|
||||
const x = Math.max(0, Math.min(pgW - sigW, viewportPt.x - sigW / 2));
|
||||
const y = Math.max(0, Math.min(pgH - sigH, viewportPt.y - sigH / 2));
|
||||
setActivePlacement({
|
||||
pageIndex: pageIdx,
|
||||
rect: { x, y, width: sigW, height: sigH, rotation: 0 },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{activeTool === 'edit_text' && (
|
||||
@@ -546,6 +574,29 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
searchResults={searchResults}
|
||||
searchCurrentMatch={searchCurrentMatch}
|
||||
/>
|
||||
|
||||
{/* Interactive signature placement overlay */}
|
||||
{activePlacement && activePlacement.pageIndex === page.index && signatureImageUrl && (
|
||||
<SignaturePlacementOverlay
|
||||
imageUrl={signatureImageUrl}
|
||||
aspect={signatureAspect || 3}
|
||||
initialRect={activePlacement.rect}
|
||||
pageWidth={page.width}
|
||||
pageHeight={page.height}
|
||||
onCommit={(finalRect) => {
|
||||
setActivePlacement(null);
|
||||
// Convert viewport rect to PDF-space coordinates
|
||||
const pageInfo = pagesInfo?.[page.index];
|
||||
const pageHPts = pageInfo ? pageInfo.height : (pageHeight || 792);
|
||||
const pdfX = finalRect.x / zoom;
|
||||
const pdfW = finalRect.width / zoom;
|
||||
const pdfH = finalRect.height / zoom;
|
||||
const pdfY = pageHPts - (finalRect.y / zoom) - pdfH;
|
||||
onPlaceSignature?.(page.index, { x: pdfX, y: pdfY, width: pdfW, height: pdfH }, finalRect.rotation);
|
||||
}}
|
||||
onCancel={() => setActivePlacement(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center bg-[#f6f7f9] text-[#5b6573] gap-3 rounded-[3px]">
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* SignaturePlacementOverlay
|
||||
* Adobe-style interactive signature placement: drag to reposition,
|
||||
* corner/edge handles to resize, rotation handle to rotate.
|
||||
* Sits as an absolute child inside the page div.
|
||||
*/
|
||||
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
||||
import { CustomButton } from '../components/custom/CustomButton';
|
||||
|
||||
export interface PlacementRect {
|
||||
/** All values in viewport-pixels relative to the page div */
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number; // degrees
|
||||
}
|
||||
|
||||
interface Props {
|
||||
imageUrl: string;
|
||||
aspect: number;
|
||||
initialRect: PlacementRect;
|
||||
pageWidth: number; // viewport px
|
||||
pageHeight: number; // viewport px
|
||||
onCommit: (rect: PlacementRect) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type Handle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'rotate' | 'move';
|
||||
|
||||
const MIN_SIZE = 40;
|
||||
|
||||
const HANDLE_CURSORS: Record<Handle, string> = {
|
||||
nw: 'nwse-resize', n: 'ns-resize', ne: 'nesw-resize',
|
||||
e: 'ew-resize', se: 'nwse-resize', s: 'ns-resize',
|
||||
sw: 'nesw-resize', w: 'ew-resize',
|
||||
rotate: 'grab', move: 'move',
|
||||
};
|
||||
|
||||
const HANDLE_POSITIONS: Record<Exclude<Handle, 'rotate' | 'move'>, { left: string; top: string }> = {
|
||||
nw: { left: '-5px', top: '-5px' },
|
||||
n: { left: 'calc(50% - 5px)', top: '-5px' },
|
||||
ne: { left: 'calc(100% - 5px)', top: '-5px' },
|
||||
e: { left: 'calc(100% - 5px)', top: 'calc(50% - 5px)' },
|
||||
se: { left: 'calc(100% - 5px)', top: 'calc(100% - 5px)' },
|
||||
s: { left: 'calc(50% - 5px)', top: 'calc(100% - 5px)' },
|
||||
sw: { left: '-5px', top: 'calc(100% - 5px)' },
|
||||
w: { left: '-5px', top: 'calc(50% - 5px)' },
|
||||
};
|
||||
|
||||
export const SignaturePlacementOverlay: React.FC<Props> = ({
|
||||
imageUrl, aspect, initialRect, pageWidth, pageHeight, onCommit, onCancel,
|
||||
}) => {
|
||||
const [rect, setRect] = useState<PlacementRect>(initialRect);
|
||||
const rectRef = useRef(rect);
|
||||
rectRef.current = rect;
|
||||
|
||||
const dragRef = useRef<{
|
||||
handle: Handle;
|
||||
startX: number; startY: number;
|
||||
startRect: PlacementRect;
|
||||
centerX: number; centerY: number;
|
||||
} | null>(null);
|
||||
|
||||
/* Clamp rect to page bounds */
|
||||
const clamp = useCallback((r: PlacementRect): PlacementRect => {
|
||||
const w = Math.max(MIN_SIZE, r.width);
|
||||
const h = Math.max(MIN_SIZE, r.height);
|
||||
const x = Math.max(0, Math.min(pageWidth - w, r.x));
|
||||
const y = Math.max(0, Math.min(pageHeight - h, r.y));
|
||||
return { ...r, x, y, width: w, height: h };
|
||||
}, [pageWidth, pageHeight]);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent, handle: Handle) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
const r = rectRef.current;
|
||||
dragRef.current = {
|
||||
handle,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startRect: { ...r },
|
||||
centerX: r.x + r.width / 2,
|
||||
centerY: r.y + r.height / 2,
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
const dx = e.clientX - d.startX;
|
||||
const dy = e.clientY - d.startY;
|
||||
const { startRect, handle } = d;
|
||||
|
||||
setRect(() => {
|
||||
let { x, y, width, height, rotation } = startRect;
|
||||
|
||||
if (handle === 'move') {
|
||||
x = startRect.x + dx;
|
||||
y = startRect.y + dy;
|
||||
} else if (handle === 'rotate') {
|
||||
// Angle from center to current mouse (uses dragRef center, not local cx/cy)
|
||||
const angle = Math.atan2(
|
||||
(e.clientY - d.centerY),
|
||||
(e.clientX - d.centerX),
|
||||
) * 180 / Math.PI + 90;
|
||||
rotation = angle;
|
||||
} else {
|
||||
// Resize — maintain aspect ratio on corners if Shift pressed (we do it always for corners)
|
||||
const isCorner = ['nw', 'ne', 'se', 'sw'].includes(handle);
|
||||
switch (handle) {
|
||||
case 'n': y = startRect.y + dy; height = startRect.height - dy; break;
|
||||
case 's': height = startRect.height + dy; break;
|
||||
case 'e': width = startRect.width + dx; break;
|
||||
case 'w': x = startRect.x + dx; width = startRect.width - dx; break;
|
||||
case 'nw': x = startRect.x + dx; y = startRect.y + dy; width = startRect.width - dx; height = startRect.height - dy; break;
|
||||
case 'ne': y = startRect.y + dy; width = startRect.width + dx; height = startRect.height - dy; break;
|
||||
case 'se': width = startRect.width + dx; height = startRect.height + dy; break;
|
||||
case 'sw': x = startRect.x + dx; width = startRect.width - dx; height = startRect.height + dy; break;
|
||||
}
|
||||
if (isCorner && e.shiftKey) {
|
||||
// Lock aspect ratio
|
||||
const newAspect = width / height;
|
||||
if (newAspect > aspect) width = height * aspect;
|
||||
else height = width / aspect;
|
||||
}
|
||||
}
|
||||
|
||||
return clamp({ x, y, width, height, rotation });
|
||||
});
|
||||
};
|
||||
|
||||
const onUp = () => { dragRef.current = null; };
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
};
|
||||
}, [clamp, aspect]);
|
||||
|
||||
/* Keyboard: Escape = cancel, Enter = commit */
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
|
||||
if (e.key === 'Enter') { e.preventDefault(); onCommit(rectRef.current); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onCancel, onCommit]);
|
||||
|
||||
const { x, y, width, height, rotation } = rect;
|
||||
const cx = x + width / 2;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Dim everything outside the signature */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute', inset: 0, zIndex: 60,
|
||||
cursor: 'default',
|
||||
background: 'rgba(15,23,42,0.18)',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
onPointerDown={(e) => { e.stopPropagation(); onCancel(); }}
|
||||
/>
|
||||
|
||||
{/* Signature overlay box */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x, top: y, width, height,
|
||||
zIndex: 61,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transformOrigin: `${width / 2}px ${height / 2}px`,
|
||||
boxSizing: 'border-box',
|
||||
outline: '2px solid #2563eb',
|
||||
outlineOffset: '-1px',
|
||||
borderRadius: 3,
|
||||
cursor: 'move',
|
||||
pointerEvents: 'auto',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onPointerDown={(e) => onPointerDown(e, 'move')}
|
||||
>
|
||||
{/* Signature image */}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="signature"
|
||||
draggable={false}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block', userSelect: 'none', pointerEvents: 'none' }}
|
||||
/>
|
||||
|
||||
{/* Corner + edge handles */}
|
||||
{(Object.entries(HANDLE_POSITIONS) as [Exclude<Handle, 'rotate' | 'move'>, { left: string; top: string }][]).map(([h, pos]) => (
|
||||
<div
|
||||
key={h}
|
||||
onPointerDown={(e) => onPointerDown(e, h)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: pos.left, top: pos.top,
|
||||
width: 10, height: 10,
|
||||
background: '#ffffff',
|
||||
border: '2px solid #2563eb',
|
||||
borderRadius: 2,
|
||||
cursor: HANDLE_CURSORS[h],
|
||||
zIndex: 2,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.18)',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Rotation handle — sits above the top center */}
|
||||
<div
|
||||
onPointerDown={(e) => onPointerDown(e, 'rotate')}
|
||||
title="Rotate"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 'calc(50% - 9px)',
|
||||
top: -34,
|
||||
width: 18, height: 18,
|
||||
background: '#2563eb',
|
||||
borderRadius: '50%',
|
||||
cursor: 'grab',
|
||||
zIndex: 3,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: '0 2px 8px rgba(37,99,235,0.4)',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Rotation stem */}
|
||||
<div style={{
|
||||
position: 'absolute', left: '50%', top: '100%',
|
||||
width: 1.5, height: 14,
|
||||
background: '#2563eb',
|
||||
transform: 'translateX(-50%)',
|
||||
}} />
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ position: 'relative', zIndex: 1 }}>
|
||||
<path d="M21.5 2v6h-6"/><path d="M21.34 15.57a10 10 0 1 1-.57-8.38"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Size badge */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: -22, left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgba(15,23,42,0.75)',
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
padding: '2px 7px',
|
||||
borderRadius: 99,
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
{Math.round(width)} × {Math.round(height)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action bar — centered below the signature box */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: cx - 130,
|
||||
top: y + height + 48,
|
||||
zIndex: 62,
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 12,
|
||||
padding: '8px 12px',
|
||||
boxShadow: '0 8px 32px rgba(15,23,42,0.18)',
|
||||
pointerEvents: 'auto',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Keyboard hint */}
|
||||
<span style={{ fontSize: 11, color: '#94a3b8', marginRight: 4 }}>
|
||||
Drag · Resize · <kbd style={{ background: '#f1f5f9', padding: '1px 4px', borderRadius: 4, border: '1px solid #e2e8f0', fontSize: 10 }}>Shift</kbd> lock ratio
|
||||
</span>
|
||||
|
||||
<div style={{ width: 1, height: 20, background: '#e2e8f0' }} />
|
||||
|
||||
{/* Cancel */}
|
||||
<CustomButton variant="outline" size="sm" onClick={onCancel}>
|
||||
Cancel <span style={{ opacity: 0.5, fontSize: 10 }}>Esc</span>
|
||||
</CustomButton>
|
||||
|
||||
{/* Confirm */}
|
||||
<CustomButton variant="primary" size="sm" onClick={() => onCommit(rect)} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<svg width="13" height="13" 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>
|
||||
Apply Signature
|
||||
<span style={{ opacity: 0.6, fontSize: 10 }}>↵</span>
|
||||
</CustomButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user