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