feat(05-04): add move and resize to placed signature fields

- Native pointer events on field body for move drag (no dnd-kit conflict)
- 10x10 resize handle in bottom-right corner with se-resize cursor
- Event delegation via DroppableZone onPointerMove/onPointerUp
- DOM mutation during drag for smooth performance; commit to state on pointerUp
- data-no-move attribute prevents resize handle and delete button from triggering move
- draggingRef tracks in-progress drag; activeDragFieldId drives grabbing cursor + box-shadow
- Minimum field size enforced: 40x20 PDF units
This commit is contained in:
Chandler Copeland
2026-03-20 01:02:45 -06:00
parent 3d6f0ea68c
commit 51a77ef7d2

View File

@@ -90,15 +90,30 @@ function DraggableToken({ id }: { id: string }) {
);
}
interface DraggingState {
type: 'move' | 'resize';
fieldId: string;
startPointerX: number;
startPointerY: number;
startFieldX: number; // PDF units
startFieldY: number;
startFieldW?: number;
startFieldH?: number;
}
// Droppable zone wrapper for the PDF canvas
function DroppableZone({
id,
containerRef,
children,
onZonePointerMove,
onZonePointerUp,
}: {
id: string;
containerRef: React.RefObject<HTMLDivElement | null>;
children: React.ReactNode;
onZonePointerMove: (e: React.PointerEvent<HTMLDivElement>) => void;
onZonePointerUp: (e: React.PointerEvent<HTMLDivElement>) => void;
}) {
const { setNodeRef } = useDroppable({ id });
@@ -111,6 +126,8 @@ function DroppableZone({
}
}}
style={{ position: 'relative' }}
onPointerMove={onZonePointerMove}
onPointerUp={onZonePointerUp}
>
{children}
</div>
@@ -133,6 +150,23 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
// Offset of the <canvas> element relative to the DroppableZone wrapper div
const [canvasOffset, setCanvasOffset] = useState({ x: 0, y: 0 });
// Track in-progress move/resize without causing re-renders
const draggingRef = useRef<DraggingState | null>(null);
// Track which field is actively being dragged/resized for cursor styling
const [activeDragFieldId, setActiveDragFieldId] = useState<string | null>(null);
const [activeDragType, setActiveDragType] = useState<'move' | 'resize' | null>(null);
// Refs for pageInfo and fields so pointer handlers always see current values
const pageInfoRef = useRef<PageInfo | null>(null);
const fieldsRef = useRef<SignatureFieldData[]>([]);
const containerSizeRef = useRef<{ w: number; h: number } | null>(null);
const canvasOffsetRef = useRef({ x: 0, y: 0 });
useEffect(() => { pageInfoRef.current = pageInfo; }, [pageInfo]);
useEffect(() => { fieldsRef.current = fields; }, [fields]);
useEffect(() => { containerSizeRef.current = containerSize; }, [containerSize]);
useEffect(() => { canvasOffsetRef.current = canvasOffset; }, [canvasOffset]);
// Configure sensors: require a minimum drag distance so clicks on delete buttons
// are not intercepted as drag starts
const sensors = useSensors(
@@ -234,6 +268,145 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
[fields, pageInfo, currentPage, docId],
);
// --- Move / Resize pointer handlers (event delegation on DroppableZone) ---
const handleMoveStart = useCallback((e: React.PointerEvent<HTMLDivElement>, fieldId: string) => {
e.stopPropagation();
const field = fieldsRef.current.find((f) => f.id === fieldId);
if (!field) return;
draggingRef.current = {
type: 'move',
fieldId,
startPointerX: e.clientX,
startPointerY: e.clientY,
startFieldX: field.x,
startFieldY: field.y,
};
setActiveDragFieldId(fieldId);
setActiveDragType('move');
e.currentTarget.setPointerCapture(e.pointerId);
}, []);
const handleResizeStart = useCallback((e: React.PointerEvent<HTMLDivElement>, fieldId: string) => {
e.stopPropagation();
const field = fieldsRef.current.find((f) => f.id === fieldId);
if (!field) return;
draggingRef.current = {
type: 'resize',
fieldId,
startPointerX: e.clientX,
startPointerY: e.clientY,
startFieldX: field.x,
startFieldY: field.y,
startFieldW: field.width,
startFieldH: field.height,
};
setActiveDragFieldId(fieldId);
setActiveDragType('resize');
e.currentTarget.setPointerCapture(e.pointerId);
}, []);
const handleZonePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const drag = draggingRef.current;
if (!drag) return;
const pi = pageInfoRef.current;
const cs = containerSizeRef.current;
if (!pi || !cs) return;
const renderedW = cs.w;
const renderedH = cs.h;
// Screen delta from drag start
const screenDx = e.clientX - drag.startPointerX;
const screenDy = e.clientY - drag.startPointerY;
// Convert screen delta to PDF units
const pdfDx = (screenDx / renderedW) * pi.originalWidth;
// Screen Y down = PDF Y decrease (Y-axis inversion)
const pdfDy = -(screenDy / renderedH) * pi.originalHeight;
if (drag.type === 'move') {
const newX = drag.startFieldX + pdfDx;
const newY = drag.startFieldY + pdfDy;
// Update DOM directly for smooth performance (commit to React state on pointerup)
const co = canvasOffsetRef.current;
const { left, top } = pdfToScreenCoords(newX, newY, renderedW, renderedH, pi);
const fieldEl = containerRef.current?.querySelector<HTMLElement>(`[data-field-id="${drag.fieldId}"]`);
if (fieldEl) {
const heightPx = parseFloat(fieldEl.style.height);
fieldEl.style.left = `${left + co.x}px`;
fieldEl.style.top = `${top - heightPx + co.y}px`;
}
} else if (drag.type === 'resize') {
const newW = Math.max(40, (drag.startFieldW ?? 144) + pdfDx);
// Dragging down (positive screenDy) increases height in PDF units
const newH = Math.max(20, (drag.startFieldH ?? 36) - pdfDy);
const fieldEl = containerRef.current?.querySelector<HTMLElement>(`[data-field-id="${drag.fieldId}"]`);
if (fieldEl) {
const widthPx = (newW / pi.originalWidth) * renderedW;
const heightPx = (newH / pi.originalHeight) * renderedH;
// Recalculate top based on fixed bottom-left origin (field.y = PDF bottom edge stays fixed)
const co = canvasOffsetRef.current;
const { left, top } = pdfToScreenCoords(drag.startFieldX, drag.startFieldY, renderedW, renderedH, pi);
fieldEl.style.width = `${widthPx}px`;
fieldEl.style.height = `${heightPx}px`;
fieldEl.style.left = `${left + co.x}px`;
fieldEl.style.top = `${top - heightPx + co.y}px`;
}
}
}, []);
const handleZonePointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const drag = draggingRef.current;
if (!drag) return;
const pi = pageInfoRef.current;
const cs = containerSizeRef.current;
draggingRef.current = null;
setActiveDragFieldId(null);
setActiveDragType(null);
if (!pi || !cs) return;
const renderedW = cs.w;
const renderedH = cs.h;
const screenDx = e.clientX - drag.startPointerX;
const screenDy = e.clientY - drag.startPointerY;
const pdfDx = (screenDx / renderedW) * pi.originalWidth;
const pdfDy = -(screenDy / renderedH) * pi.originalHeight;
const currentFields = fieldsRef.current;
if (drag.type === 'move') {
const next = currentFields.map((f) => {
if (f.id !== drag.fieldId) return f;
return {
...f,
x: drag.startFieldX + pdfDx,
y: drag.startFieldY + pdfDy,
};
});
setFields(next);
persistFields(docId, next);
} else if (drag.type === 'resize') {
const newW = Math.max(40, (drag.startFieldW ?? 144) + pdfDx);
const newH = Math.max(20, (drag.startFieldH ?? 36) - pdfDy);
const next = currentFields.map((f) => {
if (f.id !== drag.fieldId) return f;
return { ...f, width: newW, height: newH };
});
setFields(next);
persistFields(docId, next);
}
}, [docId]);
// Render placed fields for the current page
// Uses pageInfo.width/height (not getBoundingClientRect) for consistent coordinate math
const renderFields = () => {
@@ -249,9 +422,12 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
const widthPx = (field.width / pageInfo.originalWidth) * renderedW;
const heightPx = (field.height / pageInfo.originalHeight) * renderedH;
const isMoving = activeDragFieldId === field.id && activeDragType === 'move';
return (
<div
key={field.id}
data-field-id={field.id}
style={{
position: 'absolute',
left: left + canvasOffset.x,
@@ -271,10 +447,20 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
zIndex: 10,
pointerEvents: 'all',
boxSizing: 'border-box',
cursor: isMoving ? 'grabbing' : 'grab',
boxShadow: isMoving ? '0 4px 12px rgba(37,99,235,0.35)' : undefined,
userSelect: 'none',
touchAction: 'none',
}}
onPointerDown={(e) => {
// Only trigger move from the field body (not delete button or resize handle)
if ((e.target as HTMLElement).closest('[data-no-move]')) return;
handleMoveStart(e, field.id);
}}
>
<span>Signature</span>
<span style={{ pointerEvents: 'none' }}>Signature</span>
<button
data-no-move
onClick={(e) => {
// Stop dnd-kit from treating this click as a drag activation
e.stopPropagation();
@@ -283,7 +469,7 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
persistFields(docId, next);
}}
onPointerDown={(e) => {
// Prevent dnd-kit sensors from capturing this pointer event
// Prevent dnd-kit sensors and move handler from capturing this pointer event
e.stopPropagation();
}}
style={{
@@ -304,6 +490,26 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
>
×
</button>
{/* Resize handle — bottom-right corner */}
<div
data-no-move
style={{
position: 'absolute',
right: 0,
bottom: 0,
width: 10,
height: 10,
cursor: 'se-resize',
background: '#2563eb',
borderRadius: '2px 0 2px 0',
zIndex: 12,
pointerEvents: 'all',
}}
onPointerDown={(e) => {
e.stopPropagation();
handleResizeStart(e, field.id);
}}
/>
</div>
);
});
@@ -333,7 +539,12 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
</div>
{/* Droppable PDF zone with field overlays */}
<DroppableZone id="pdf-drop-zone" containerRef={containerRef}>
<DroppableZone
id="pdf-drop-zone"
containerRef={containerRef}
onZonePointerMove={handleZonePointerMove}
onZonePointerUp={handleZonePointerUp}
>
{children}
{renderFields()}
</DroppableZone>