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);
// 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 <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
// 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 <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 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',