feat: add Sign PDF tool (draw/type/upload signatures, place on a PDF) (#370)

Draw, type, or upload a signature and place resizable/rotatable copies across PDF pages; output flattened server-side with PyMuPDF. Visual electronic signature, not cryptographic. New interactive-sign display mode (pdf.js + Konva) and a custom docs-pool route.
This commit is contained in:
SnapOtter
2026-06-29 11:01:25 +08:00
committed by GitHub
parent 1c202c6ef0
commit 0cdd560ac4
51 changed files with 2223 additions and 10 deletions
@@ -0,0 +1,352 @@
import type { SignPlacement } from "@snapotter/shared";
import Konva from "konva";
import * as pdfjs from "pdfjs-dist";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { toNormalizedRect } from "@/lib/sign-geometry";
import type { SavedSignature } from "@/lib/signature-store";
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
const RENDER_SCALE = 1.5; // on-screen scale; placements are normalized so this is cosmetic
const EXPORT_QUALITY = 2; // raster the baked PNG at ~2x page points for crispness
const KONVA_CONTAINER_ID = "sign-konva-container";
/** Rendered size (px) and page size (points) for one page, captured at render time. */
interface PageMeta {
sizeW: number;
sizeH: number;
ptsW: number;
ptsH: number;
}
/** A placed signature node, tagged with the page it belongs to. */
interface PlacedSig {
id: string;
page: number;
node: Konva.Image;
}
export interface SignCanvasRef {
addSignature: (sig: SavedSignature) => void;
deleteSelected: () => void;
exportPlacements: () => Promise<{ pngs: Blob[]; placements: SignPlacement[] }>;
hasPlacements: () => boolean;
}
interface Props {
fileUrl: string;
onSelectionChange?: (hasSelection: boolean) => void;
onCountChange?: (count: number) => void;
}
export const SignCanvas = forwardRef<SignCanvasRef, Props>(function SignCanvas(
{ fileUrl, onSelectionChange, onCountChange },
ref,
) {
const { t } = useTranslation();
const pdfCanvasRef = useRef<HTMLCanvasElement>(null);
const stageRef = useRef<Konva.Stage | null>(null);
const layerRef = useRef<Konva.Layer | null>(null);
const trRef = useRef<Konva.Transformer | null>(null);
const docRef = useRef<pdfjs.PDFDocumentProxy | null>(null);
// Flat list of every placed node across all pages. Nodes live here for the
// component's lifetime; page navigation only attaches/detaches them from the
// layer (never destroys), so revisiting a page keeps its signatures.
const placementsRef = useRef<PlacedSig[]>([]);
// Per-page render size (px) + page size (points), captured when each page renders.
const pageMetaRef = useRef<Map<number, PageMeta>>(new Map());
const [page, setPage] = useState(0);
const [pageCount, setPageCount] = useState(1);
const [size, setSize] = useState({ w: 0, h: 0 });
// Flips true once the document is loaded. Drives the render effect to run for
// the first page, since setPage(0) is a no-op when page is already 0 (and
// pageCount never changes for single-page PDFs).
const [docReady, setDocReady] = useState(false);
// Load the document once per file. A replaced file starts clean: drop the
// previous document's placements and stage so they don't carry over.
// biome-ignore lint/correctness/useExhaustiveDependencies: reset + reload run only on file change; the parent callbacks are stable setters
useEffect(() => {
let cancelled = false;
setDocReady(false);
for (const placed of placementsRef.current) placed.node.destroy();
placementsRef.current = [];
pageMetaRef.current.clear();
stageRef.current?.destroy();
stageRef.current = null;
layerRef.current = null;
trRef.current = null;
onCountChange?.(0);
onSelectionChange?.(false);
(async () => {
const doc = await pdfjs.getDocument({ url: fileUrl }).promise;
if (cancelled) return;
docRef.current = doc;
setPageCount(doc.numPages);
setPage(0);
setDocReady(true);
})();
return () => {
cancelled = true;
docRef.current?.loadingTask.destroy();
};
}, [fileUrl]);
// Render the current page and show that page's nodes. The Konva stage is
// created once (lazily) and reused; switching pages resizes it and swaps which
// signature nodes are attached.
useEffect(() => {
if (!docReady) return;
let cancelled = false;
(async () => {
const doc = docRef.current;
const canvas = pdfCanvasRef.current;
if (!doc || !canvas) return;
const pdfPage = await doc.getPage(page + 1);
if (cancelled) return;
const ptsViewport = pdfPage.getViewport({ scale: 1 });
const viewport = pdfPage.getViewport({ scale: RENDER_SCALE });
canvas.width = viewport.width;
canvas.height = viewport.height;
setSize({ w: viewport.width, h: viewport.height });
await pdfPage.render({ canvas, viewport }).promise;
if (cancelled) return;
pageMetaRef.current.set(page, {
sizeW: viewport.width,
sizeH: viewport.height,
ptsW: ptsViewport.width,
ptsH: ptsViewport.height,
});
let stage = stageRef.current;
if (!stage) {
stage = new Konva.Stage({
container: KONVA_CONTAINER_ID,
width: viewport.width,
height: viewport.height,
});
const layer = new Konva.Layer();
const tr = new Konva.Transformer({
rotateEnabled: true,
keepRatio: true,
enabledAnchors: ["top-left", "top-right", "bottom-left", "bottom-right"],
// Keep resize/rotate within the page bounds.
boundBoxFunc: (oldBox, newBox) => {
const s = stageRef.current;
if (!s) return newBox;
if (
newBox.x < 0 ||
newBox.y < 0 ||
newBox.x + newBox.width > s.width() ||
newBox.y + newBox.height > s.height()
) {
return oldBox;
}
return newBox;
},
});
layer.add(tr);
stage.add(layer);
stageRef.current = stage;
layerRef.current = layer;
trRef.current = tr;
stage.on("click tap", (e) => {
if (e.target === stage) {
tr.nodes([]);
onSelectionChange?.(false);
}
});
} else {
stage.width(viewport.width);
stage.height(viewport.height);
}
const layer = layerRef.current;
const tr = trRef.current;
if (!layer || !tr) return;
// Detach the previously shown signatures (keep the transformer), then
// attach this page's. Nodes are never destroyed here.
tr.nodes([]);
for (const child of [...layer.getChildren()]) {
if (child instanceof Konva.Image) child.remove();
}
for (const placed of placementsRef.current) {
if (placed.page === page) layer.add(placed.node);
}
layer.batchDraw();
onSelectionChange?.(false);
})();
return () => {
cancelled = true;
};
}, [page, docReady, onSelectionChange]);
// Destroy the stage and all nodes on unmount.
useEffect(() => {
return () => {
for (const placed of placementsRef.current) placed.node.destroy();
placementsRef.current = [];
stageRef.current?.destroy();
stageRef.current = null;
};
}, []);
const emitCount = () => onCountChange?.(placementsRef.current.length);
// biome-ignore lint/correctness/useExhaustiveDependencies: the handle reads page/size snapshots; parent callbacks are stable and deliberately excluded to avoid rebuilding the handle every render
useImperativeHandle(
ref,
() => ({
addSignature(sig) {
const layer = layerRef.current;
const tr = trRef.current;
if (!layer || !tr) return;
const img = new window.Image();
img.onload = () => {
const targetW = size.w * 0.28;
const scale = targetW / img.width;
const node = new Konva.Image({
image: img,
x: size.w * 0.36,
y: size.h * 0.45,
width: img.width * scale,
height: img.height * scale,
draggable: true,
});
// Keep the dragged signature's bounding box within the page.
node.dragBoundFunc((pos) => {
const s = stageRef.current;
if (!s) return pos;
const box = node.getClientRect({ relativeTo: s });
const dx = box.x - node.x();
const dy = box.y - node.y();
let x = pos.x;
let y = pos.y;
if (x + dx < 0) x = -dx;
if (y + dy < 0) y = -dy;
if (x + dx + box.width > s.width()) x = s.width() - box.width - dx;
if (y + dy + box.height > s.height()) y = s.height() - box.height - dy;
return { x, y };
});
node.on("click tap", () => {
tr.nodes([node]);
onSelectionChange?.(true);
});
layer.add(node);
tr.nodes([node]);
layer.batchDraw();
placementsRef.current.push({ id: crypto.randomUUID(), page, node });
onSelectionChange?.(true);
emitCount();
};
img.src = sig.dataUrl;
},
deleteSelected() {
const tr = trRef.current;
const layer = layerRef.current;
if (!tr || !layer) return;
for (const n of tr.nodes()) {
placementsRef.current = placementsRef.current.filter((p) => p.node !== n);
n.destroy();
}
tr.nodes([]);
layer.batchDraw();
onSelectionChange?.(false);
emitCount();
},
hasPlacements() {
return placementsRef.current.length > 0;
},
async exportPlacements() {
const pngs: Blob[] = [];
const placements: SignPlacement[] = [];
const layer = layerRef.current;
const tr = trRef.current;
const selected = tr?.nodes() ?? [];
tr?.nodes([]);
// Detach all visible signatures; we re-attach each node one at a time so
// getClientRect/toDataURL run on an attached node regardless of page.
if (layer) {
for (const child of [...layer.getChildren()]) {
if (child instanceof Konva.Image) child.remove();
}
}
let sigIndex = 0;
for (const placed of placementsRef.current) {
const meta = pageMetaRef.current.get(placed.page);
if (!meta || !layer) continue;
layer.add(placed.node);
const box = placed.node.getClientRect({ relativeTo: layer });
const norm = toNormalizedRect(
{ x: box.x, y: box.y, w: box.width, h: box.height },
meta.sizeW,
meta.sizeH,
);
placements.push({
sig: sigIndex,
page: placed.page,
x: norm.x,
y: norm.y,
w: norm.w,
h: norm.h,
});
const ratio = (EXPORT_QUALITY * meta.ptsW) / meta.sizeW;
const dataUrl = placed.node.toDataURL({ pixelRatio: ratio });
pngs.push(await (await fetch(dataUrl)).blob());
placed.node.remove();
sigIndex++;
}
// Restore the current page's view.
if (layer) {
for (const placed of placementsRef.current) {
if (placed.page === page) layer.add(placed.node);
}
if (selected.length) tr?.nodes(selected);
layer.batchDraw();
}
return { pngs, placements };
},
}),
[page, size],
);
return (
<div className="flex flex-1 flex-col items-center gap-3 p-4">
<div className="relative" style={{ width: size.w, height: size.h }}>
<canvas
ref={pdfCanvasRef}
data-testid="sign-pdf-canvas"
className="rounded border border-border shadow"
/>
<div id={KONVA_CONTAINER_ID} className="absolute inset-0" />
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<button
type="button"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
className="rounded border border-border px-2 py-1 disabled:opacity-40"
>
{t.tools.documentView.previousPage}
</button>
<span className="font-medium text-foreground">
{page + 1} / {pageCount}
</span>
<button
type="button"
disabled={page >= pageCount - 1}
onClick={() => setPage((p) => p + 1)}
className="rounded border border-border px-2 py-1 disabled:opacity-40"
>
{t.tools.documentView.nextPage}
</button>
</div>
</div>
);
});
@@ -0,0 +1,325 @@
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
import { format } from "@/lib/format";
import {
addSignature,
deleteSignature,
listSignatures,
type SavedSignature,
} from "@/lib/signature-store";
import { generateId } from "@/lib/utils";
import { useFileStore } from "@/stores/file-store";
import type { SignCanvasRef } from "./sign-canvas";
import { SignaturePad } from "./signature-pad";
const SSE_STALL_TIMEOUT_MS = 5 * 60_000;
interface ProgressHandlers {
onProgress?: (percent: number) => void;
onComplete: (result: Record<string, unknown>) => void;
onFailed: (error: string) => void;
onStall: () => void;
}
/**
* Subscribe to async (202) job progress with the same mobile-resilient recovery
* as the standard tool processor (PRs #203/#204). Reconnects on tab refocus (the
* progress endpoint replays the terminal frame from its Redis cache, so a job
* that finished while SSE was dead still resolves) and arms a stall timeout that
* fails gracefully instead of hanging at the last percent. Returns a cleanup the
* caller must invoke on sync completion, error, or unmount.
*/
function subscribeJobProgress(clientJobId: string, handlers: ProgressHandlers): () => void {
let es: EventSource | null = null;
let stall: ReturnType<typeof setTimeout> | null = null;
let done = false;
const onVisible = () => {
if (done || document.visibilityState !== "visible") return;
if (es && es.readyState === EventSource.OPEN) return;
setTimeout(open, 500);
};
const cleanup = () => {
if (done) return;
done = true;
if (stall) clearTimeout(stall);
stall = null;
if (es) es.close();
es = null;
document.removeEventListener("visibilitychange", onVisible);
};
const resetStall = () => {
if (stall) clearTimeout(stall);
stall = setTimeout(() => {
cleanup();
handlers.onStall();
}, SSE_STALL_TIMEOUT_MS);
};
function open() {
if (done) return;
if (es && es.readyState === EventSource.OPEN) return;
if (es) es.close();
try {
es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
} catch {
return;
}
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type !== "single") return;
resetStall();
if (data.phase === "complete" && data.result) {
cleanup();
handlers.onComplete(data.result as Record<string, unknown>);
return;
}
if (data.phase === "failed") {
cleanup();
handlers.onFailed(typeof data.error === "string" ? data.error : "Processing failed");
return;
}
if (typeof data.percent === "number") handlers.onProgress?.(data.percent);
} catch {
// Ignore malformed SSE frames
}
};
// A transient drop triggers the browser's built-in reconnect; on reconnect
// the backend replays the terminal frame, so a completed job still resolves.
es.onerror = () => {};
}
document.addEventListener("visibilitychange", onVisible);
open();
resetStall();
return cleanup;
}
export interface SignProps {
canvasRef: React.RefObject<SignCanvasRef | null>;
hasSelection: boolean;
placementCount: number;
}
export function SignPdfSettings({ signProps }: { signProps?: SignProps }) {
const { t } = useTranslation();
const sp = t.toolSettings["sign-pdf"];
const { currentEntry } = useFileStore();
const [sigs, setSigs] = useState<SavedSignature[]>(() => listSignatures());
const [padOpen, setPadOpen] = useState(false);
const [processing, setProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const progressCleanupRef = useRef<(() => void) | null>(null);
// Tear down any live SSE subscription if the panel unmounts mid-job.
useEffect(() => () => progressCleanupRef.current?.(), []);
const refresh = () => setSigs(listSignatures());
const handleSavePad = (dataUrl: string, remember: boolean) => {
const sig: SavedSignature = remember
? addSignature(dataUrl)
: { id: crypto.randomUUID(), dataUrl, createdAt: Date.now() };
if (remember) refresh();
signProps?.canvasRef.current?.addSignature(sig);
setPadOpen(false);
};
const handleApply = async () => {
const canvas = signProps?.canvasRef.current;
const file = currentEntry?.file;
if (!canvas || !file) return;
if (!canvas.hasPlacements()) {
setError(sp.addFirst);
return;
}
setError(null);
setDownloadUrl(null);
setProgress(0);
setProcessing(true);
const { pngs, placements } = await canvas.exportPlacements();
const clientJobId = generateId();
const finish = () => {
progressCleanupRef.current = null;
setProcessing(false);
};
const stopProgress = subscribeJobProgress(clientJobId, {
onProgress: (percent) => setProgress(percent),
onComplete: (r) => {
setDownloadUrl(r.downloadUrl as string);
finish();
},
onFailed: (err) => {
setError(err);
finish();
},
onStall: () => {
setError(
"Processing timed out. The result may have saved to your files; otherwise, try again.",
);
finish();
},
});
progressCleanupRef.current = stopProgress;
const form = new FormData();
form.append("file", file);
form.append("placements", JSON.stringify(placements));
form.append("clientJobId", clientJobId);
// Forward the library file id (when the PDF came from the library) so the
// worker auto-saves the signed result as a new version.
if (currentEntry?.serverFileId) form.append("fileId", currentEntry.serverFileId);
pngs.forEach((png, i) => {
form.append(`sig${i}`, new File([png], `sig${i}.png`, { type: "image/png" }));
});
const xhr = new XMLHttpRequest();
xhr.timeout = 600_000;
xhr.onload = () => {
// 202 = async: subscribeJobProgress drives completion via SSE.
if (xhr.status === 202) return;
stopProgress();
progressCleanupRef.current = null;
if (xhr.status >= 200 && xhr.status < 300) {
try {
setDownloadUrl(JSON.parse(xhr.responseText).downloadUrl);
} catch {
setError("Invalid response");
}
} else {
try {
const b = JSON.parse(xhr.responseText);
setError(
typeof b.error === "string"
? b.error
: typeof b.details === "string"
? b.details
: `Failed: ${xhr.status}`,
);
} catch {
setError(`Processing failed: ${xhr.status}`);
}
}
setProcessing(false);
};
xhr.onerror = () => {
stopProgress();
progressCleanupRef.current = null;
setError("Network error");
setProcessing(false);
};
xhr.ontimeout = () => {
stopProgress();
progressCleanupRef.current = null;
setError("Request timed out. Try again.");
setProcessing(false);
};
xhr.open("POST", "/api/v1/tools/pdf/sign-pdf");
formatHeaders().forEach((value, key) => {
xhr.setRequestHeader(key, value);
});
xhr.send(form);
};
return (
<div className="space-y-4">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{sp.yourSignatures}
</p>
<div className="mt-2 flex flex-wrap gap-2">
{sigs.map((s) => (
<div key={s.id} className="group relative">
<button
type="button"
onClick={() => signProps?.canvasRef.current?.addSignature(s)}
className="h-9 min-w-[60px] rounded border border-border bg-background p-1"
>
<img
src={s.dataUrl}
alt="saved signature"
className="h-full w-full object-contain"
/>
</button>
<button
type="button"
aria-label="delete signature"
onClick={() => {
deleteSignature(s.id);
refresh();
}}
className="absolute -end-1 -top-1 hidden h-4 w-4 rounded-full bg-destructive text-[10px] text-white group-hover:block"
>
</button>
</div>
))}
<button
type="button"
onClick={() => setPadOpen(true)}
className="h-9 min-w-[60px] rounded border border-dashed border-border text-xs text-muted-foreground"
>
+ {sp.newSignature}
</button>
</div>
<p className="mt-1 text-[11px] text-muted-foreground">{sp.clickToPlace}</p>
</div>
<div className="border-t border-border" />
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{sp.selectedSignature}
</p>
<button
type="button"
disabled={!signProps?.hasSelection}
onClick={() => signProps?.canvasRef.current?.deleteSelected()}
className="mt-2 rounded border border-border px-2 py-1 text-xs text-destructive disabled:opacity-40"
>
{t.common.delete}
</button>
<p className="mt-1 text-[11px] text-muted-foreground">{sp.dragToAdjust}</p>
</div>
<p className="text-[11px] text-muted-foreground">{sp.disclaimer}</p>
{error && <p className="text-sm text-destructive">{error}</p>}
{downloadUrl ? (
<a
href={downloadUrl}
download
className="block w-full rounded-lg bg-primary py-2.5 text-center font-semibold text-primary-foreground"
>
{sp.downloadSigned}
</a>
) : (
<button
type="button"
disabled={processing || (signProps?.placementCount ?? 0) === 0}
onClick={handleApply}
className="w-full rounded-lg bg-primary py-2.5 font-semibold text-primary-foreground disabled:opacity-50"
>
{processing
? progress > 0
? format(sp.signingPercent, { percent: Math.round(progress) })
: sp.signing
: t.toolPage.applyAndDownload}
</button>
)}
{padOpen && <SignaturePad onSave={handleSavePad} onCancel={() => setPadOpen(false)} />}
</div>
);
}
@@ -0,0 +1,296 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
const INK_COLORS = ["#13315c", "#1a1814", "#1f6feb"];
const PEN_WIDTHS = { S: 2, M: 3.5, L: 6 } as const;
const FONTS = [
{ label: "Signature", css: "'Brush Script MT', 'Segoe Script', cursive" },
{ label: "Cursive", css: "'Snell Roundhand', 'Apple Chancery', cursive" },
{ label: "Italic", css: "Georgia, serif" },
];
type Tab = "draw" | "type" | "upload";
export interface SignaturePadProps {
onSave: (dataUrl: string, remember: boolean) => void;
onCancel: () => void;
}
/** Trim transparent margins; returns a tightly-cropped PNG data URL or null. */
function cropToInk(source: HTMLCanvasElement): string | null {
const ctx = source.getContext("2d");
if (!ctx) return null;
const { width, height } = source;
const { data } = ctx.getImageData(0, 0, width, height);
let minX = width;
let minY = height;
let maxX = 0;
let maxY = 0;
let found = false;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (data[(y * width + x) * 4 + 3] > 8) {
found = true;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (!found) return null;
const pad = 8;
const w = Math.min(width, maxX - minX + pad * 2);
const h = Math.min(height, maxY - minY + pad * 2);
const out = document.createElement("canvas");
out.width = w;
out.height = h;
out.getContext("2d")?.drawImage(source, minX - pad, minY - pad, w, h, 0, 0, w, h);
return out.toDataURL("image/png");
}
export function SignaturePad({ onSave, onCancel }: SignaturePadProps) {
const { t } = useTranslation();
const pad = t.toolSettings["sign-pdf"].pad;
const tabLabels: Record<Tab, string> = { draw: pad.draw, type: pad.type, upload: pad.upload };
const [tab, setTab] = useState<Tab>("draw");
const [remember, setRemember] = useState(true);
const [color, setColor] = useState(INK_COLORS[0]);
const [width, setWidth] = useState<keyof typeof PEN_WIDTHS>("M");
const [hasInk, setHasInk] = useState(false);
const [typed, setTyped] = useState("");
const [font, setFont] = useState(FONTS[0]);
const [uploaded, setUploaded] = useState<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const drawing = useRef(false);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || tab !== "draw") return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const point = (e: PointerEvent) => {
const r = canvas.getBoundingClientRect();
return {
x: (e.clientX - r.left) * (canvas.width / r.width),
y: (e.clientY - r.top) * (canvas.height / r.height),
};
};
const down = (e: PointerEvent) => {
drawing.current = true;
const p = point(e);
ctx.beginPath();
ctx.moveTo(p.x, p.y);
canvas.setPointerCapture(e.pointerId);
};
const move = (e: PointerEvent) => {
if (!drawing.current) return;
const p = point(e);
ctx.strokeStyle = color;
ctx.lineWidth = PEN_WIDTHS[width];
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.lineTo(p.x, p.y);
ctx.stroke();
setHasInk(true);
};
const up = () => {
drawing.current = false;
};
canvas.addEventListener("pointerdown", down);
canvas.addEventListener("pointermove", move);
canvas.addEventListener("pointerup", up);
return () => {
canvas.removeEventListener("pointerdown", down);
canvas.removeEventListener("pointermove", move);
canvas.removeEventListener("pointerup", up);
};
}, [tab, color, width]);
const clearDraw = () => {
const canvas = canvasRef.current;
canvas?.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height);
setHasInk(false);
};
const renderTyped = (): string | null => {
if (!typed.trim()) return null;
const c = document.createElement("canvas");
c.width = 600;
c.height = 200;
const ctx = c.getContext("2d");
if (!ctx) return null;
ctx.fillStyle = color;
ctx.font = `64px ${font.css}`;
ctx.textBaseline = "middle";
ctx.fillText(typed, 20, 100);
return cropToInk(c);
};
const onUpload = (file: File) => {
const reader = new FileReader();
reader.onload = () => setUploaded(reader.result as string);
reader.readAsDataURL(file);
};
const canSave =
(tab === "draw" && hasInk) ||
(tab === "type" && typed.trim() !== "") ||
(tab === "upload" && uploaded !== null);
const handleSave = () => {
let dataUrl: string | null = null;
if (tab === "draw" && canvasRef.current) dataUrl = cropToInk(canvasRef.current);
else if (tab === "type") dataUrl = renderTyped();
else if (tab === "upload") dataUrl = uploaded;
if (dataUrl) onSave(dataUrl, remember);
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
role="dialog"
aria-modal="true"
aria-label={pad.title}
>
<div className="w-[460px] max-w-[92vw] rounded-xl border border-border bg-background shadow-2xl">
<div className="flex items-center justify-between border-b border-border p-3">
<h2 className="text-sm font-semibold">{pad.title}</h2>
<button
type="button"
onClick={onCancel}
aria-label={t.common.close}
className="text-muted-foreground"
>
</button>
</div>
<div className="flex gap-1 px-3 pt-3">
{(["draw", "type", "upload"] as Tab[]).map((tb) => (
<button
key={tb}
type="button"
onClick={() => setTab(tb)}
className={`rounded-t-lg border border-b-0 px-3 py-1.5 text-sm capitalize ${tab === tb ? "border-primary bg-background font-medium" : "border-border bg-muted text-muted-foreground"}`}
>
{tabLabels[tb]}
</button>
))}
</div>
<div className="border-t border-border p-4">
{tab === "draw" && (
<>
<canvas
ref={canvasRef}
width={600}
height={220}
className="h-[180px] w-full rounded-lg border border-dashed border-border"
style={{ touchAction: "none" }}
/>
<div className="mt-3 flex items-center gap-3">
<span className="text-xs text-muted-foreground">{pad.color}</span>
{INK_COLORS.map((c) => (
<button
key={c}
type="button"
aria-label={`ink ${c}`}
onClick={() => setColor(c)}
className={`h-5 w-5 rounded-full ${color === c ? "ring-2 ring-primary ring-offset-1" : ""}`}
style={{ background: c }}
/>
))}
<span className="ms-2 text-xs text-muted-foreground">{pad.pen}</span>
{(Object.keys(PEN_WIDTHS) as Array<keyof typeof PEN_WIDTHS>).map((w) => (
<button
key={w}
type="button"
onClick={() => setWidth(w)}
className={`h-6 w-6 rounded border text-xs ${width === w ? "border-primary bg-primary/10" : "border-border"}`}
>
{w}
</button>
))}
<button
type="button"
onClick={clearDraw}
className="ms-auto rounded border border-border px-2 py-1 text-xs"
>
{t.common.clear}
</button>
</div>
</>
)}
{tab === "type" && (
<>
<input
value={typed}
onChange={(e) => setTyped(e.target.value)}
placeholder={pad.namePlaceholder}
className="w-full rounded-lg border border-border px-3 py-2 text-sm"
/>
<div className="mt-3 flex flex-col gap-2">
{FONTS.map((f) => (
<button
key={f.label}
type="button"
onClick={() => setFont(f)}
className={`rounded-lg border px-3 py-2 text-start ${font.label === f.label ? "border-primary bg-primary/10" : "border-border"}`}
style={{ fontFamily: f.css, color }}
>
{typed || pad.yourName}
</button>
))}
</div>
</>
)}
{tab === "upload" && (
<>
<label className="flex cursor-pointer flex-col items-center rounded-lg border border-dashed border-border p-4 text-center text-xs text-muted-foreground">
{pad.uploadHint}
<input
type="file"
accept="image/png,image/jpeg"
className="hidden"
onChange={(e) => e.target.files?.[0] && onUpload(e.target.files[0])}
/>
</label>
{uploaded && (
<img
src={uploaded}
alt="signature preview"
className="mt-3 h-12 rounded border border-border object-contain"
/>
)}
</>
)}
</div>
<div className="flex items-center gap-3 border-t border-border bg-muted/40 p-3">
<label className="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
/>{" "}
{pad.remember}
</label>
<button
type="button"
onClick={onCancel}
className="ms-auto rounded-lg border border-border px-3 py-1.5 text-sm"
>
{t.common.cancel}
</button>
<button
type="button"
disabled={!canSave}
onClick={handleSave}
className="rounded-lg bg-primary px-4 py-1.5 text-sm font-semibold text-primary-foreground disabled:opacity-50"
>
{pad.save}
</button>
</div>
</div>
</div>
);
}
+2
View File
@@ -90,6 +90,7 @@ import {
Scissors,
ShieldCheck,
ShieldOff,
Signature,
SlidersHorizontal,
Sparkles,
Split,
@@ -203,6 +204,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
ScanText,
ShieldCheck,
ShieldOff,
Signature,
SlidersHorizontal,
Sparkles,
Split,
+30
View File
@@ -0,0 +1,30 @@
export interface RectPx {
x: number;
y: number;
w: number;
h: number;
}
export interface NormRect {
x: number;
y: number;
w: number;
h: number;
}
/** Convert a pixel rect (top-left origin) to page fractions (0..1). */
export function toNormalizedRect(rect: RectPx, renderW: number, renderH: number): NormRect {
return {
x: rect.x / renderW,
y: rect.y / renderH,
w: rect.w / renderW,
h: rect.h / renderH,
};
}
/** Axis-aligned bounding box of a w×h rect rotated by `deg` degrees. */
export function rotatedBoundingBox(w: number, h: number, deg: number): { w: number; h: number } {
const r = (deg * Math.PI) / 180;
const c = Math.abs(Math.cos(r));
const s = Math.abs(Math.sin(r));
return { w: w * c + h * s, h: w * s + h * c };
}
+45
View File
@@ -0,0 +1,45 @@
export interface SavedSignature {
id: string;
dataUrl: string;
createdAt: number;
}
export const MAX_SIGNATURES = 10;
const KEY = "snapotter.signatures.v1";
function read(): SavedSignature[] {
try {
const raw = localStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as SavedSignature[]) : [];
} catch {
return [];
}
}
function write(list: SavedSignature[]): boolean {
try {
localStorage.setItem(KEY, JSON.stringify(list));
return true;
} catch {
return false; // QuotaExceededError -> caller falls back to session-only
}
}
export function listSignatures(): SavedSignature[] {
return read().sort((a, b) => b.createdAt - a.createdAt);
}
export function addSignature(dataUrl: string): SavedSignature {
const sig: SavedSignature = {
id: crypto.randomUUID(),
dataUrl,
createdAt: Date.now(),
};
const list = [...read(), sig].slice(-MAX_SIGNATURES); // keep newest MAX
write(list);
return sig;
}
export function deleteSignature(id: string): void {
write(read().filter((s) => s.id !== id));
}
+2
View File
@@ -15,6 +15,7 @@ export type DisplayMode =
| "no-comparison"
| "interactive-crop"
| "interactive-eraser"
| "interactive-sign"
| "interactive-split"
| "no-dropzone"
| "custom-results"
@@ -183,6 +184,7 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
"pdfa-convert": "no-comparison",
"flatten-pdf": "document",
"redact-pdf": "document",
"sign-pdf": "interactive-sign",
"pdf-to-text": "no-comparison",
"pdf-to-word": "no-comparison",
"pdf-metadata": "no-comparison",
+6
View File
@@ -18,6 +18,7 @@ import type { Crop } from "react-image-crop";
import type { BgPreviewState } from "@/components/common/image-viewer";
import type { EraserCanvasRef } from "@/components/tools/eraser-canvas";
import type { PreviewTransform } from "@/components/tools/rotate-settings";
import type { SignProps } from "@/components/tools/sign-pdf-settings";
import { TOOL_DISPLAY_MODES } from "./tool-display-modes";
// ── Display modes ──────────────────────────────────────────────────
@@ -70,6 +71,7 @@ export interface ToolRegistryEntry {
onImageOverlay?: (children: React.ReactNode) => void;
cropProps?: CropProps;
eraserProps?: EraserProps;
signProps?: SignProps;
}>;
/** Optional panel for tools that render custom content in the main area. */
ResultsPanel?: React.ComponentType;
@@ -721,6 +723,9 @@ const RedactPdfSettings = lazy(() =>
default: m.RedactPdfSettings,
})),
);
const SignPdfSettings = lazy(() =>
import("@/components/tools/sign-pdf-settings").then((m) => ({ default: m.SignPdfSettings })),
);
const PdfToTextSettings = lazy(() =>
import("@/components/tools/pdf-to-text-settings").then((m) => ({
default: m.PdfToTextSettings,
@@ -1113,6 +1118,7 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [
["pdfa-convert", { accept: ".pdf", Settings: PdfaConvertSettings }],
["flatten-pdf", { accept: ".pdf", Settings: FlattenPdfSettings }],
["redact-pdf", { accept: ".pdf", Settings: RedactPdfSettings }],
["sign-pdf", { accept: ".pdf", Settings: SignPdfSettings }],
["pdf-to-text", { accept: ".pdf", Settings: PdfToTextSettings }],
["pdf-to-word", { accept: ".pdf", Settings: PdfToWordSettings }],
["pdf-metadata", { accept: ".pdf", Settings: PdfMetadataSettings }],
+25
View File
@@ -36,6 +36,7 @@ import { CropCanvas } from "@/components/tools/crop-canvas";
import type { EraserCanvasRef } from "@/components/tools/eraser-canvas";
import { EraserCanvas } from "@/components/tools/eraser-canvas";
import type { PreviewTransform } from "@/components/tools/rotate-settings";
import { SignCanvas, type SignCanvasRef } from "@/components/tools/sign-canvas";
import { useTranslation } from "@/contexts/i18n-context";
import { useAuth } from "@/hooks/use-auth";
import { useMobile } from "@/hooks/use-mobile";
@@ -359,6 +360,11 @@ export function ToolPage() {
// Center of the painted mask as a 0-100 percentage — used to init the slider at the right spot
const [eraserSliderInitPos, setEraserSliderInitPos] = useState<number | null>(null);
// Sign state
const signCanvasRef = useRef<SignCanvasRef | null>(null);
const [signHasSelection, setSignHasSelection] = useState(false);
const [signPlacementCount, setSignPlacementCount] = useState(0);
// Page-level drag overlay state
const [isDraggingOver, setIsDraggingOver] = useState(false);
const dragCounter = useRef(0);
@@ -651,6 +657,14 @@ export function ToolPage() {
maskedFileCount: eraserMaskedCount,
}
: undefined,
signProps:
displayMode === "interactive-sign"
? {
canvasRef: signCanvasRef,
hasSelection: signHasSelection,
placementCount: signPlacementCount,
}
: undefined,
};
const ToolSettings = registryEntry.Settings;
@@ -843,6 +857,17 @@ export function ToolPage() {
);
}
if (displayMode === "interactive-sign" && hasFile && originalBlobUrl) {
return (
<SignCanvas
ref={signCanvasRef}
fileUrl={originalBlobUrl}
onSelectionChange={setSignHasSelection}
onCountChange={setSignPlacementCount}
/>
);
}
if (displayMode === "interactive-split" && hasFile && originalBlobUrl) {
if (registryEntry?.ResultsPanel) {
const Panel = registryEntry.ResultsPanel;