From 51a77ef7d2b9b70150f4473806f75bfe5bfd6563 Mon Sep 17 00:00:00 2001 From: Chandler Copeland Date: Fri, 20 Mar 2026 01:02:45 -0600 Subject: [PATCH] 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 --- .../[docId]/_components/FieldPlacer.tsx | 217 +++++++++++++++++- 1 file changed, 214 insertions(+), 3 deletions(-) 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 83bb1a3..29a19b7 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 @@ -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; children: React.ReactNode; + onZonePointerMove: (e: React.PointerEvent) => void; + onZonePointerUp: (e: React.PointerEvent) => void; }) { const { setNodeRef } = useDroppable({ id }); @@ -111,6 +126,8 @@ function DroppableZone({ } }} style={{ position: 'relative' }} + onPointerMove={onZonePointerMove} + onPointerUp={onZonePointerUp} > {children} @@ -133,6 +150,23 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla // Offset of the 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(null); + // Track which field is actively being dragged/resized for cursor styling + const [activeDragFieldId, setActiveDragFieldId] = useState(null); + const [activeDragType, setActiveDragType] = useState<'move' | 'resize' | null>(null); + + // Refs for pageInfo and fields so pointer handlers always see current values + const pageInfoRef = useRef(null); + const fieldsRef = useRef([]); + 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, 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, 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) => { + 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(`[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(`[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) => { + 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 (
{ + // 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); }} > - Signature + Signature + {/* Resize handle — bottom-right corner */} +
{ + e.stopPropagation(); + handleResizeStart(e, field.id); + }} + />
); }); @@ -333,7 +539,12 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla
{/* Droppable PDF zone with field overlays */} - + {children} {renderFields()}