Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/hooks/useSortableDrag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export function useSortableDrag(onMove: (fromIndex: number, toSlot: number) => v
touchStartPos.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
isDragActive.current = false;
},
// Prevent Android Chrome long-press context menu ("Open in new tab" etc.).
onContextMenu(e: React.MouseEvent) {
e.preventDefault();
},
Comment on lines +44 to +47
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onContextMenu is unconditionally prevented for all draggable items, which also disables the right-click context menu on desktop (not just Android long-press). Consider gating preventDefault() to touch-driven interactions (e.g., only after a touchstart/drag is detected) so standard desktop context menus remain available.

Copilot uses AI. Check for mistakes.
// Prevent iOS long-press callout / page preview from hijacking drag.
style: {
WebkitTouchCallout: "none",
Expand Down
96 changes: 72 additions & 24 deletions src/tools/AddSignature.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { PenLine, Upload, Move, Maximize2, Check, CheckSquare, X } from "lucide-
type SignatureMode = "draw" | "upload";

const MAX_UPLOAD_SIZE = 5 * 1024 * 1024; // 5 MB
const DEFAULT_POSITION = { xPercent: 50, yPercent: 15 };

/**
* Apply a colour tint to an image data-URL.
Expand Down Expand Up @@ -86,7 +87,10 @@ export default function AddSignature() {
const [processing, setProcessing] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [position, setPosition] = useState({ xPercent: 50, yPercent: 15 });
const [position, setPosition] = useState(DEFAULT_POSITION);
const [pagePositions, setPagePositions] = useState<
Record<number, { xPercent: number; yPercent: number }>
>({});
const [sigSize, setSigSize] = useState({ width: 200, height: 80 });
const [pageDims, setPageDims] = useState<{ width: number; height: number }[]>([]);
const [isDragging, setIsDragging] = useState(false);
Expand Down Expand Up @@ -120,6 +124,22 @@ export default function AddSignature() {
};
}, [mode, uploadedImageUrl, tintEnabled, color]);

// Derive current position: per-page when not applying to all, shared otherwise
const currentPosition = applyToAllPages
? position
: (pagePositions[selectedPage] ?? DEFAULT_POSITION);

const setCurrentPosition = useCallback(
(pos: { xPercent: number; yPercent: number }) => {
if (applyToAllPages) {
setPosition(pos);
} else {
setPagePositions((prev) => ({ ...prev, [selectedPage]: pos }));
}
},
[applyToAllPages, selectedPage],
);

/* ---- drag-and-drop positioning ---- */
const handleDragStart = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
Expand All @@ -131,12 +151,12 @@ export default function AddSignature() {
active: true,
startX: clientX,
startY: clientY,
startXPct: position.xPercent,
startYPct: position.yPercent,
startXPct: currentPosition.xPercent,
startYPct: currentPosition.yPercent,
};
setIsDragging(true);
},
[position],
[currentPosition],
);

useEffect(() => {
Expand All @@ -150,7 +170,7 @@ export default function AddSignature() {
const dy = ((dragRef.current.startY - clientY) / rect.height) * 100;
const newX = Math.max(2, Math.min(98, dragRef.current.startXPct + dx));
const newY = Math.max(2, Math.min(98, dragRef.current.startYPct + dy));
setPosition({ xPercent: Math.round(newX), yPercent: Math.round(newY) });
setCurrentPosition({ xPercent: Math.round(newX), yPercent: Math.round(newY) });
};

const handleUp = () => {
Expand All @@ -168,7 +188,7 @@ export default function AddSignature() {
window.removeEventListener("touchmove", handleMove);
window.removeEventListener("touchend", handleUp);
};
}, []);
}, [setCurrentPosition]);

/* ---- page toggle for multi-select ---- */
const togglePage = useCallback((index: number) => {
Expand Down Expand Up @@ -242,32 +262,56 @@ export default function AddSignature() {
const arrayBuffer = await file.arrayBuffer();
const { PDFDocument } = await import("@pdfme/pdf-lib");
const pdfDoc = await PDFDocument.load(arrayBuffer);
const page = pdfDoc.getPage(selectedPage);
const { width: pageWidth, height: pageHeight } = page.getSize();

// Convert centre-based percentage position to bottom-left origin PDF coords
const x = (position.xPercent / 100) * pageWidth - sigSize.width / 2;
const y = (position.yPercent / 100) * pageHeight - sigSize.height / 2;

const pageIndices = applyToAllPages
? Array.from({ length: pdfDoc.getPageCount() }, (_, i) => i)
: [...selectedPages].sort((a, b) => a - b);

const result = await addSignature(file, signatureDataUrl, pageIndices, {
x: Math.max(0, x),
y: Math.max(0, y),
width: sigSize.width,
height: sigSize.height,
});
if (applyToAllPages) {
// Single shared position
const page = pdfDoc.getPage(0);
const { width: pageWidth, height: pageHeight } = page.getSize();
const x = (position.xPercent / 100) * pageWidth - sigSize.width / 2;
const y = (position.yPercent / 100) * pageHeight - sigSize.height / 2;
Comment on lines +271 to +275
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When applying to all pages, the PDF coordinates are computed using page 0’s dimensions and then reused for every page. If the PDF contains pages with different sizes, the signature won’t stay at the same percentage position (and may end up off-page). Consider computing coordinates per page (e.g., build a per-page position map even for apply-to-all) or moving the percent->points conversion into addSignature so it can use each page’s size.

Copilot uses AI. Check for mistakes.

const result = await addSignature(file, signatureDataUrl, pageIndices, {
x: Math.max(0, x),
y: Math.max(0, y),
width: sigSize.width,
height: sigSize.height,
});
const baseName = file.name.replace(/\.pdf$/i, "");
downloadPdf(result, `${baseName}_signed.pdf`);
} else {
// Per-page positions
const positionMap = new Map<
number,
{ x: number; y: number; width: number; height: number }
>();
for (const idx of pageIndices) {
const page = pdfDoc.getPage(idx);
const { width: pageWidth, height: pageHeight } = page.getSize();
const pos = pagePositions[idx] ?? DEFAULT_POSITION;
const x = (pos.xPercent / 100) * pageWidth - sigSize.width / 2;
const y = (pos.yPercent / 100) * pageHeight - sigSize.height / 2;
positionMap.set(idx, {
x: Math.max(0, x),
y: Math.max(0, y),
width: sigSize.width,
height: sigSize.height,
});
}

const baseName = file.name.replace(/\.pdf$/i, "");
downloadPdf(result, `${baseName}_signed.pdf`);
const result = await addSignature(file, signatureDataUrl, pageIndices, positionMap);
const baseName = file.name.replace(/\.pdf$/i, "");
downloadPdf(result, `${baseName}_signed.pdf`);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to add signature. Please try again.");
} finally {
setProcessing(false);
}
}, [file, signatureDataUrl, selectedPage, position, sigSize, applyToAllPages, selectedPages]);
}, [file, signatureDataUrl, position, sigSize, applyToAllPages, selectedPages, pagePositions]);

return (
<div className="space-y-6">
Expand All @@ -293,6 +337,7 @@ export default function AddSignature() {
setSignatureDataUrl("");
setUploadedImageUrl("");
setSelectedPages(new Set());
setPagePositions({});
}}
className="text-sm text-primary-600 hover:text-primary-700"
>
Expand Down Expand Up @@ -479,7 +524,10 @@ export default function AddSignature() {
<div className="flex items-start gap-2 rounded-lg bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 px-3 py-2.5">
<Move className="w-4 h-4 mt-0.5 text-primary-500 shrink-0" />
<p className="text-xs text-primary-700 dark:text-primary-300 leading-relaxed">
Drag the signature on the preview to reposition it
Drag the signature on the preview to reposition it.
{thumbnails.length > 1 &&
!applyToAllPages &&
" Each page remembers its own position — select a page and drag to adjust."}
</p>
</div>
)}
Expand Down Expand Up @@ -588,8 +636,8 @@ export default function AddSignature() {
<div
className="absolute border-2 border-dashed border-primary-400 rounded select-none touch-none"
style={{
left: `${position.xPercent}%`,
bottom: `${position.yPercent}%`,
left: `${currentPosition.xPercent}%`,
bottom: `${currentPosition.yPercent}%`,
transform: "translate(-50%, 50%)",
cursor: isDragging ? "grabbing" : "grab",
width: pageDims[selectedPage]
Expand Down
15 changes: 10 additions & 5 deletions src/utils/pdf-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,7 +647,7 @@ export async function addSignature(
file: File,
signatureDataUrl: string,
pageIndices: number[],
position: Position,
position: Position | Map<number, Position>,
): Promise<Uint8Array> {
const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer);
Expand All @@ -665,13 +665,18 @@ export async function addSignature(
? await pdf.embedJpg(signatureBytes)
: await pdf.embedPng(signatureBytes);

const isMap = position instanceof Map;

for (const idx of pageIndices) {
const fallback = isMap ? position.values().next().value : position;
const pos = isMap ? (position.get(idx) ?? fallback) : position;
if (!pos) continue;
Comment on lines 670 to +673
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the Map case, missing entries fall back to the first Map value, and an empty Map silently results in no signatures being drawn (pos becomes undefined and the loop continues). This fallback behavior is fragile/implicit for API consumers—consider validating that the map contains positions for all pageIndices (or providing an explicit default position) and handling the empty-map case with a clear error.

Suggested change
for (const idx of pageIndices) {
const fallback = isMap ? position.values().next().value : position;
const pos = isMap ? (position.get(idx) ?? fallback) : position;
if (!pos) continue;
if (isMap) {
if (position.size === 0) {
throw new Error("Signature position map must not be empty.");
}
const missingPageIndices = pageIndices.filter((idx) => !position.has(idx));
if (missingPageIndices.length > 0) {
throw new Error(
`Signature position map is missing positions for page indices: ${missingPageIndices.join(", ")}`,
);
}
}
for (const idx of pageIndices) {
const pos = isMap ? position.get(idx)! : position;

Copilot uses AI. Check for mistakes.
const page = pdf.getPage(idx);
page.drawImage(signatureImage, {
x: position.x,
y: position.y,
width: position.width,
height: position.height,
x: pos.x,
y: pos.y,
width: pos.width,
height: pos.height,
});
}

Expand Down
Loading