diff --git a/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx b/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx index dc6fb0e..f569ceb 100644 --- a/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx +++ b/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx @@ -130,6 +130,8 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla const containerRef = useRef(null); // Track rendered canvas dimensions in state so renderFields re-runs when they change const [containerSize, setContainerSize] = useState<{ w: number; h: number } | null>(null); + // Offset of the 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 // 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 }); }, [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( (event: DragEndEvent) => { setIsDraggingToken(false); - const { over, activatorEvent, delta } = event; - - // Only process if dropped onto the PDF zone + const { over, active } = event; if (!over || over.id !== 'pdf-drop-zone') return; if (!pageInfo) return; - const containerRect = containerRef.current?.getBoundingClientRect(); - if (!containerRect) return; + // Use the ghost's final bounding rect so the field appears exactly where + // 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 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 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 - let clientX: number; - let clientY: number; + // Ghost's top-left position relative to the canvas + const rawX = ghostRect.left - refRect.left; + const rawY = ghostRect.top - refRect.top; - if (activatorEvent instanceof MouseEvent) { - clientX = activatorEvent.clientX; - clientY = activatorEvent.clientY; - } 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; - } + // Field dimensions in screen pixels (for clamping) + const fieldWpx = (144 / pageInfo.originalWidth) * renderedW; + const fieldHpx = (36 / pageInfo.originalHeight) * renderedH; - // finalClient = activatorClient + delta (displacement during drag) - const finalClientX = clientX + delta.x; - const finalClientY = clientY + delta.y; + // Clamp so field stays within canvas bounds + const clampedX = Math.max(0, Math.min(rawX, renderedW - fieldWpx)); + const clampedY = Math.max(0, Math.min(rawY, renderedH - fieldHpx)); - // Convert to coordinates relative to the container's top-left corner - const screenX = finalClientX - containerRect.left; - const screenY = finalClientY - containerRect.top; - - // 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, renderedW, renderedH, pageInfo); + // Convert to PDF user-space coordinates. + // pdfX = left edge (X increases right in both screen and PDF). + // pdfY = bottom edge (PDF Y=0 is bottom, increases upward). + const pdfX = (clampedX / renderedW) * pageInfo.originalWidth; + const pdfY = ((renderedH - (clampedY + fieldHpx)) / renderedH) * pageInfo.originalHeight; const newField: SignatureFieldData = { id: crypto.randomUUID(), page: currentPage, x: pdfX, y: pdfY, - width: 144, // 2 inches at 72 DPI - height: 36, // 0.5 inches at 72 DPI + width: 144, + height: 36, }; const next = [...fields, newField]; @@ -250,8 +254,8 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla key={field.id} style={{ position: 'absolute', - left, - top: top - heightPx, // top is y of bottom-left corner; shift up by height + left: left + canvasOffset.x, + top: top - heightPx + canvasOffset.y, // top is y of bottom-left corner; shift up by height width: widthPx, height: heightPx, border: '2px solid #2563eb',