done the signature module

This commit is contained in:
azeeee05
2026-07-01 15:05:37 +05:30
parent afa8c0a702
commit bc3ed20a8b
8 changed files with 1200 additions and 128 deletions
+43
View File
@@ -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?
+4 -5
View File
@@ -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')}
+759 -106
View File
@@ -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>
);
};
+15 -12
View File
@@ -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>
)}
+7
View File
@@ -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; } }
+11 -3
View File
@@ -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,
});
}
};
+53 -2
View File
@@ -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>
</>
);
};