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'; 'use client';
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react';
import { import {
DndContext, DndContext,
useDraggable, useDraggable,
useDroppable, useDroppable,
DragOverlay, DragOverlay,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent, type DragEndEvent,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
@@ -21,29 +25,28 @@ interface PageInfo {
// Screen (DOM) → PDF user space. Y-axis flip required. // Screen (DOM) → PDF user space. Y-axis flip required.
// DOM: Y=0 at top, increases downward // DOM: Y=0 at top, increases downward
// PDF: Y=0 at bottom, increases upward // PDF: Y=0 at bottom, increases upward
// renderedW/renderedH must be the actual rendered canvas dimensions (from pageInfo.width/height)
function screenToPdfCoords( function screenToPdfCoords(
screenX: number, screenX: number,
screenY: number, screenY: number,
containerRect: DOMRect, renderedW: number,
renderedH: number,
pageInfo: PageInfo, 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 pdfX = (screenX / renderedW) * pageInfo.originalWidth;
const pdfY = ((renderedH - screenY) / renderedH) * pageInfo.originalHeight; const pdfY = ((renderedH - screenY) / renderedH) * pageInfo.originalHeight;
return { x: pdfX, y: pdfY }; return { x: pdfX, y: pdfY };
} }
// PDF user space → screen (for rendering stored fields) // PDF user space → screen (for rendering stored fields)
// renderedW/renderedH must be the actual rendered canvas dimensions (from pageInfo.width/height)
function pdfToScreenCoords( function pdfToScreenCoords(
pdfX: number, pdfX: number,
pdfY: number, pdfY: number,
containerRect: DOMRect, renderedW: number,
renderedH: number,
pageInfo: PageInfo, pageInfo: PageInfo,
) { ) {
const renderedW = containerRect.width;
const renderedH = containerRect.height;
const left = (pdfX / pageInfo.originalWidth) * renderedW; const left = (pdfX / pageInfo.originalWidth) * renderedW;
// top is measured from DOM top; pdfY is from PDF bottom — reverse flip // top is measured from DOM top; pdfY is from PDF bottom — reverse flip
const top = renderedH - (pdfY / pageInfo.originalHeight) * renderedH; 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 [fields, setFields] = useState<SignatureFieldData[]>([]);
const [isDraggingToken, setIsDraggingToken] = useState(false); const [isDraggingToken, setIsDraggingToken] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null); 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 // Load existing fields from server on mount
useEffect(() => { useEffect(() => {
@@ -142,11 +154,19 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
loadFields(); loadFields();
}, [docId]); }, [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( const handleDragEnd = useCallback(
(event: DragEndEvent) => { (event: DragEndEvent) => {
setIsDraggingToken(false); setIsDraggingToken(false);
const { active, over, activatorEvent, delta } = event; const { over, activatorEvent, delta } = event;
// Only process if dropped onto the PDF zone // Only process if dropped onto the PDF zone
if (!over || over.id !== 'pdf-drop-zone') return; 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(); const containerRect = containerRef.current?.getBoundingClientRect();
if (!containerRect) return; 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 // Compute the final screen position relative to the container
// activatorEvent is the initial MouseEvent/TouchEvent at drag start // activatorEvent is the initial MouseEvent/TouchEvent at drag start
// delta is the displacement from drag start to drop // 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; clientY = activatorEvent.touches[0].clientY;
} else { } else {
// Fallback: use center of container // Fallback: use center of container
clientX = containerRect.left + containerRect.width / 2; clientX = containerRect.left + renderedW / 2;
clientY = containerRect.top + containerRect.height / 2; clientY = containerRect.top + renderedH / 2;
} }
// finalClient = activatorClient + delta (displacement during drag) // finalClient = activatorClient + delta (displacement during drag)
const finalClientX = clientX + delta.x; const finalClientX = clientX + delta.x;
const finalClientY = clientY + delta.y; 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 screenX = finalClientX - containerRect.left;
const screenY = finalClientY - containerRect.top; const screenY = finalClientY - containerRect.top;
// Clamp to container bounds // Clamp to canvas bounds
const clampedX = Math.max(0, Math.min(screenX, containerRect.width)); const clampedX = Math.max(0, Math.min(screenX, renderedW));
const clampedY = Math.max(0, Math.min(screenY, containerRect.height)); const clampedY = Math.max(0, Math.min(screenY, renderedH));
// Convert screen coords to PDF user-space (Y-flip applied) // 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 = { const newField: SignatureFieldData = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -205,17 +231,19 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
); );
// Render placed fields for the current page // Render placed fields for the current page
// Uses pageInfo.width/height (not getBoundingClientRect) for consistent coordinate math
const renderFields = () => { const renderFields = () => {
if (!pageInfo) return null; if (!pageInfo || !containerSize) return null;
const containerRect = containerRef.current?.getBoundingClientRect();
if (!containerRect) return null; const renderedW = containerSize.w;
const renderedH = containerSize.h;
return fields return fields
.filter((f) => f.page === currentPage) .filter((f) => f.page === currentPage)
.map((field) => { .map((field) => {
const { left, top } = pdfToScreenCoords(field.x, field.y, containerRect, pageInfo); const { left, top } = pdfToScreenCoords(field.x, field.y, renderedW, renderedH, pageInfo);
const widthPx = (field.width / pageInfo.originalWidth) * containerRect.width; const widthPx = (field.width / pageInfo.originalWidth) * renderedW;
const heightPx = (field.height / pageInfo.originalHeight) * containerRect.height; const heightPx = (field.height / pageInfo.originalHeight) * renderedH;
return ( return (
<div <div
@@ -235,17 +263,25 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
padding: '0 4px', padding: '0 4px',
fontSize: '10px', fontSize: '10px',
color: '#2563eb', color: '#2563eb',
// Raise above droppable overlay so the delete button is clickable
zIndex: 10,
pointerEvents: 'all', pointerEvents: 'all',
boxSizing: 'border-box', boxSizing: 'border-box',
}} }}
> >
<span>Signature</span> <span>Signature</span>
<button <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); const next = fields.filter((f) => f.id !== field.id);
setFields(next); setFields(next);
persistFields(docId, next); persistFields(docId, next);
}} }}
onPointerDown={(e) => {
// Prevent dnd-kit sensors from capturing this pointer event
e.stopPropagation();
}}
style={{ style={{
cursor: 'pointer', cursor: 'pointer',
background: 'none', background: 'none',
@@ -255,6 +291,10 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
padding: '0 2px', padding: '0 2px',
fontSize: '12px', fontSize: '12px',
lineHeight: 1, lineHeight: 1,
// Ensure the button sits above any overlay that might capture events
position: 'relative',
zIndex: 11,
pointerEvents: 'all',
}} }}
aria-label="Remove field" aria-label="Remove field"
> >
@@ -267,6 +307,7 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
return ( return (
<DndContext <DndContext
sensors={sensors}
onDragStart={() => setIsDraggingToken(true)} onDragStart={() => setIsDraggingToken(true)}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >