fix(05-04): fix signature field placement coordinate math

- Use pageInfo.width/height (authoritative rendered canvas size from
  react-pdf) instead of containerRect.width/height for all coordinate
  math; containerRect dimensions could differ if the wrapper div has
  extra decoration not matching the canvas
- Track containerSize in state (updated via useLayoutEffect when
  pageInfo changes) so renderFields() uses stable values from state
  instead of calling getBoundingClientRect() during render
- Refactor screenToPdfCoords/pdfToScreenCoords to take renderedW/H
  as explicit params instead of a DOMRect, making the contract clearer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chandler Copeland
2026-03-20 00:18:23 -06:00
parent 5bd77c1368
commit 126e10dc1d

View File

@@ -1,10 +1,14 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react';
import {
DndContext,
useDraggable,
useDroppable,
DragOverlay,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
@@ -21,29 +25,28 @@ interface PageInfo {
// Screen (DOM) → PDF user space. Y-axis flip required.
// DOM: Y=0 at top, increases downward
// PDF: Y=0 at bottom, increases upward
// renderedW/renderedH must be the actual rendered canvas dimensions (from pageInfo.width/height)
function screenToPdfCoords(
screenX: number,
screenY: number,
containerRect: DOMRect,
renderedW: number,
renderedH: number,
pageInfo: PageInfo,
) {
// Use containerRect dimensions (current rendered size) not stale pageInfo
const renderedW = containerRect.width;
const renderedH = containerRect.height;
const pdfX = (screenX / renderedW) * pageInfo.originalWidth;
const pdfY = ((renderedH - screenY) / renderedH) * pageInfo.originalHeight;
return { x: pdfX, y: pdfY };
}
// PDF user space → screen (for rendering stored fields)
// renderedW/renderedH must be the actual rendered canvas dimensions (from pageInfo.width/height)
function pdfToScreenCoords(
pdfX: number,
pdfY: number,
containerRect: DOMRect,
renderedW: number,
renderedH: number,
pageInfo: PageInfo,
) {
const renderedW = containerRect.width;
const renderedH = containerRect.height;
const left = (pdfX / pageInfo.originalWidth) * renderedW;
// top is measured from DOM top; pdfY is from PDF bottom — reverse flip
const top = renderedH - (pdfY / pageInfo.originalHeight) * renderedH;
@@ -125,6 +128,15 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
const [fields, setFields] = useState<SignatureFieldData[]>([]);
const [isDraggingToken, setIsDraggingToken] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
// Track rendered canvas dimensions in state so renderFields re-runs when they change
const [containerSize, setContainerSize] = useState<{ w: number; h: number } | null>(null);
// Configure sensors: require a minimum drag distance so clicks on delete buttons
// are not intercepted as drag starts
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }),
);
// Load existing fields from server on mount
useEffect(() => {
@@ -142,11 +154,19 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
loadFields();
}, [docId]);
// Update containerSize whenever pageInfo changes (page load or zoom change)
// Use pageInfo.width/height (from react-pdf canvas) as the authoritative rendered size.
// getBoundingClientRect() is only used for the drop origin (containerRect.left/top).
useLayoutEffect(() => {
if (!pageInfo) return;
setContainerSize({ w: pageInfo.width, h: pageInfo.height });
}, [pageInfo]);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setIsDraggingToken(false);
const { active, over, activatorEvent, delta } = event;
const { over, activatorEvent, delta } = event;
// Only process if dropped onto the PDF zone
if (!over || over.id !== 'pdf-drop-zone') return;
@@ -155,6 +175,12 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
const containerRect = containerRef.current?.getBoundingClientRect();
if (!containerRect) return;
// Use pageInfo.width/height as the authoritative rendered canvas size —
// containerRect.width/height could be slightly off if the wrapper div has
// any extra decoration that doesn't match the canvas exactly.
const renderedW = pageInfo.width;
const renderedH = pageInfo.height;
// Compute the final screen position relative to the container
// activatorEvent is the initial MouseEvent/TouchEvent at drag start
// delta is the displacement from drag start to drop
@@ -169,24 +195,24 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
clientY = activatorEvent.touches[0].clientY;
} else {
// Fallback: use center of container
clientX = containerRect.left + containerRect.width / 2;
clientY = containerRect.top + containerRect.height / 2;
clientX = containerRect.left + renderedW / 2;
clientY = containerRect.top + renderedH / 2;
}
// finalClient = activatorClient + delta (displacement during drag)
const finalClientX = clientX + delta.x;
const finalClientY = clientY + delta.y;
// Convert to coordinates relative to the container
// Convert to coordinates relative to the container's top-left corner
const screenX = finalClientX - containerRect.left;
const screenY = finalClientY - containerRect.top;
// Clamp to container bounds
const clampedX = Math.max(0, Math.min(screenX, containerRect.width));
const clampedY = Math.max(0, Math.min(screenY, containerRect.height));
// Clamp to canvas bounds
const clampedX = Math.max(0, Math.min(screenX, renderedW));
const clampedY = Math.max(0, Math.min(screenY, renderedH));
// Convert screen coords to PDF user-space (Y-flip applied)
const { x: pdfX, y: pdfY } = screenToPdfCoords(clampedX, clampedY, containerRect, pageInfo);
const { x: pdfX, y: pdfY } = screenToPdfCoords(clampedX, clampedY, renderedW, renderedH, pageInfo);
const newField: SignatureFieldData = {
id: crypto.randomUUID(),
@@ -205,17 +231,19 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
);
// Render placed fields for the current page
// Uses pageInfo.width/height (not getBoundingClientRect) for consistent coordinate math
const renderFields = () => {
if (!pageInfo) return null;
const containerRect = containerRef.current?.getBoundingClientRect();
if (!containerRect) return null;
if (!pageInfo || !containerSize) return null;
const renderedW = containerSize.w;
const renderedH = containerSize.h;
return fields
.filter((f) => f.page === currentPage)
.map((field) => {
const { left, top } = pdfToScreenCoords(field.x, field.y, containerRect, pageInfo);
const widthPx = (field.width / pageInfo.originalWidth) * containerRect.width;
const heightPx = (field.height / pageInfo.originalHeight) * containerRect.height;
const { left, top } = pdfToScreenCoords(field.x, field.y, renderedW, renderedH, pageInfo);
const widthPx = (field.width / pageInfo.originalWidth) * renderedW;
const heightPx = (field.height / pageInfo.originalHeight) * renderedH;
return (
<div
@@ -235,17 +263,25 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
padding: '0 4px',
fontSize: '10px',
color: '#2563eb',
// Raise above droppable overlay so the delete button is clickable
zIndex: 10,
pointerEvents: 'all',
boxSizing: 'border-box',
}}
>
<span>Signature</span>
<button
onClick={() => {
onClick={(e) => {
// Stop dnd-kit from treating this click as a drag activation
e.stopPropagation();
const next = fields.filter((f) => f.id !== field.id);
setFields(next);
persistFields(docId, next);
}}
onPointerDown={(e) => {
// Prevent dnd-kit sensors from capturing this pointer event
e.stopPropagation();
}}
style={{
cursor: 'pointer',
background: 'none',
@@ -255,6 +291,10 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
padding: '0 2px',
fontSize: '12px',
lineHeight: 1,
// Ensure the button sits above any overlay that might capture events
position: 'relative',
zIndex: 11,
pointerEvents: 'all',
}}
aria-label="Remove field"
>
@@ -267,6 +307,7 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
return (
<DndContext
sensors={sensors}
onDragStart={() => setIsDraggingToken(true)}
onDragEnd={handleDragEnd}
>