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:
@@ -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}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user