fix(05-04): use ghost rect for field placement, canvas offset for overlays

- Replace cursor+delta calculation with active.rect.current.translated (ghost bounding rect)
- Measure coordinates relative to the inner <canvas> element, not the DroppableZone wrapper
- Add canvasOffset state + useLayoutEffect to offset field overlays by canvas position within wrapper
- Inline PDF coordinate math in handleDragEnd; clamp uses field pixel dimensions
This commit is contained in:
Chandler Copeland
2026-03-20 00:40:18 -06:00
parent 13cdd150f1
commit f0ecfd1545

View File

@@ -130,6 +130,8 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
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 // Track rendered canvas dimensions in state so renderFields re-runs when they change
const [containerSize, setContainerSize] = useState<{ w: number; h: number } | null>(null); const [containerSize, setContainerSize] = useState<{ w: number; h: number } | null>(null);
// Offset of the <canvas> element relative to the DroppableZone wrapper div
const [canvasOffset, setCanvasOffset] = useState({ x: 0, y: 0 });
// Configure sensors: require a minimum drag distance so clicks on delete buttons // Configure sensors: require a minimum drag distance so clicks on delete buttons
// are not intercepted as drag starts // are not intercepted as drag starts
@@ -162,65 +164,67 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
setContainerSize({ w: pageInfo.width, h: pageInfo.height }); setContainerSize({ w: pageInfo.width, h: pageInfo.height });
}, [pageInfo]); }, [pageInfo]);
// Track the canvas element's offset within the DroppableZone wrapper div so that
// field overlays (position: absolute relative to the wrapper) are placed correctly.
useLayoutEffect(() => {
if (!pageInfo || !containerRef.current) return;
const canvas = containerRef.current.querySelector('canvas');
if (!canvas) return;
const canvasRect = canvas.getBoundingClientRect();
const containerRect = containerRef.current.getBoundingClientRect();
setCanvasOffset({
x: canvasRect.left - containerRect.left,
y: canvasRect.top - containerRect.top,
});
}, [pageInfo]);
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
(event: DragEndEvent) => { (event: DragEndEvent) => {
setIsDraggingToken(false); setIsDraggingToken(false);
const { over, activatorEvent, delta } = event; const { over, active } = event;
// Only process if dropped onto the PDF zone
if (!over || over.id !== 'pdf-drop-zone') return; if (!over || over.id !== 'pdf-drop-zone') return;
if (!pageInfo) return; if (!pageInfo) return;
const containerRect = containerRef.current?.getBoundingClientRect(); // Use the ghost's final bounding rect so the field appears exactly where
if (!containerRect) return; // the ghost was — not at cursor, which is offset by the grab point.
const ghostRect = active.rect.current.translated;
if (!ghostRect) return;
// Use the actual <canvas> element as the coordinate reference to avoid any
// offset between the DroppableZone wrapper div and the inner canvas.
const canvas = containerRef.current?.querySelector('canvas');
const refRect = (canvas ?? containerRef.current)?.getBoundingClientRect();
if (!refRect) 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 renderedW = pageInfo.width;
const renderedH = pageInfo.height; const renderedH = pageInfo.height;
// Compute the final screen position relative to the container // Ghost's top-left position relative to the canvas
// activatorEvent is the initial MouseEvent/TouchEvent at drag start const rawX = ghostRect.left - refRect.left;
// delta is the displacement from drag start to drop const rawY = ghostRect.top - refRect.top;
let clientX: number;
let clientY: number;
if (activatorEvent instanceof MouseEvent) { // Field dimensions in screen pixels (for clamping)
clientX = activatorEvent.clientX; const fieldWpx = (144 / pageInfo.originalWidth) * renderedW;
clientY = activatorEvent.clientY; const fieldHpx = (36 / pageInfo.originalHeight) * renderedH;
} else if (activatorEvent instanceof TouchEvent && activatorEvent.touches.length > 0) {
clientX = activatorEvent.touches[0].clientX;
clientY = activatorEvent.touches[0].clientY;
} else {
// Fallback: use center of container
clientX = containerRect.left + renderedW / 2;
clientY = containerRect.top + renderedH / 2;
}
// finalClient = activatorClient + delta (displacement during drag) // Clamp so field stays within canvas bounds
const finalClientX = clientX + delta.x; const clampedX = Math.max(0, Math.min(rawX, renderedW - fieldWpx));
const finalClientY = clientY + delta.y; const clampedY = Math.max(0, Math.min(rawY, renderedH - fieldHpx));
// Convert to coordinates relative to the container's top-left corner // Convert to PDF user-space coordinates.
const screenX = finalClientX - containerRect.left; // pdfX = left edge (X increases right in both screen and PDF).
const screenY = finalClientY - containerRect.top; // pdfY = bottom edge (PDF Y=0 is bottom, increases upward).
const pdfX = (clampedX / renderedW) * pageInfo.originalWidth;
// Clamp to canvas bounds const pdfY = ((renderedH - (clampedY + fieldHpx)) / renderedH) * pageInfo.originalHeight;
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, renderedW, renderedH, pageInfo);
const newField: SignatureFieldData = { const newField: SignatureFieldData = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
page: currentPage, page: currentPage,
x: pdfX, x: pdfX,
y: pdfY, y: pdfY,
width: 144, // 2 inches at 72 DPI width: 144,
height: 36, // 0.5 inches at 72 DPI height: 36,
}; };
const next = [...fields, newField]; const next = [...fields, newField];
@@ -250,8 +254,8 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
key={field.id} key={field.id}
style={{ style={{
position: 'absolute', position: 'absolute',
left, left: left + canvasOffset.x,
top: top - heightPx, // top is y of bottom-left corner; shift up by height top: top - heightPx + canvasOffset.y, // top is y of bottom-left corner; shift up by height
width: widthPx, width: widthPx,
height: heightPx, height: heightPx,
border: '2px solid #2563eb', border: '2px solid #2563eb',