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