mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user