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 29a19b7..87c0fed 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,8 +90,11 @@ function DraggableToken({ id }: { id: string }) { ); } +type ResizeCorner = 'se' | 'sw' | 'ne' | 'nw'; + interface DraggingState { type: 'move' | 'resize'; + corner?: ResizeCorner; fieldId: string; startPointerX: number; startPointerY: number; @@ -139,9 +142,10 @@ interface FieldPlacerProps { pageInfo: PageInfo | null; currentPage: number; children: React.ReactNode; + readOnly?: boolean; } -export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPlacerProps) { +export function FieldPlacer({ docId, pageInfo, currentPage, children, readOnly = false }: FieldPlacerProps) { const [fields, setFields] = useState([]); const [isDraggingToken, setIsDraggingToken] = useState(false); const containerRef = useRef(null); @@ -216,6 +220,8 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla (event: DragEndEvent) => { setIsDraggingToken(false); + if (readOnly) return; + const { over, active } = event; if (!over || over.id !== 'pdf-drop-zone') return; if (!pageInfo) return; @@ -265,12 +271,13 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla setFields(next); persistFields(docId, next); }, - [fields, pageInfo, currentPage, docId], + [fields, pageInfo, currentPage, docId, readOnly], ); // --- Move / Resize pointer handlers (event delegation on DroppableZone) --- const handleMoveStart = useCallback((e: React.PointerEvent, fieldId: string) => { + if (readOnly) return; e.stopPropagation(); const field = fieldsRef.current.find((f) => f.id === fieldId); if (!field) return; @@ -286,15 +293,17 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla setActiveDragFieldId(fieldId); setActiveDragType('move'); e.currentTarget.setPointerCapture(e.pointerId); - }, []); + }, [readOnly]); - const handleResizeStart = useCallback((e: React.PointerEvent, fieldId: string) => { + const handleResizeStart = useCallback((e: React.PointerEvent, fieldId: string, corner: ResizeCorner) => { + if (readOnly) return; e.stopPropagation(); const field = fieldsRef.current.find((f) => f.id === fieldId); if (!field) return; draggingRef.current = { type: 'resize', + corner, fieldId, startPointerX: e.clientX, startPointerY: e.clientY, @@ -306,7 +315,7 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla setActiveDragFieldId(fieldId); setActiveDragType('resize'); e.currentTarget.setPointerCapture(e.pointerId); - }, []); + }, [readOnly]); const handleZonePointerMove = useCallback((e: React.PointerEvent) => { const drag = draggingRef.current; @@ -342,22 +351,71 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla 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 corner = drag.corner ?? 'se'; + const startW = drag.startFieldW ?? 144; + const startH = drag.startFieldH ?? 36; + + // Compute start rect in screen pixels + const startLeft = (drag.startFieldX / pi.originalWidth) * renderedW; + // pdfToScreenCoords returns top of bottom edge; field screen top = that - heightPx + const startBottomScreenY = renderedH - (drag.startFieldY / pi.originalHeight) * renderedH; + const startHeightPx = (startH / pi.originalHeight) * renderedH; + const startTopScreenY = startBottomScreenY - startHeightPx; + const startRightScreenX = startLeft + (startW / pi.originalWidth) * renderedW; + + // For each corner, the OPPOSITE corner is the anchor. + // Compute new screen rect from pointer delta. + let newLeft = startLeft; + let newTop = startTopScreenY; + let newRight = startRightScreenX; + let newBottom = startBottomScreenY; + + if (corner === 'se') { + // anchor: top-left; move: right and bottom + newRight = startRightScreenX + screenDx; + newBottom = startBottomScreenY + screenDy; + } else if (corner === 'sw') { + // anchor: top-right; move: left and bottom + newLeft = startLeft + screenDx; + newBottom = startBottomScreenY + screenDy; + } else if (corner === 'ne') { + // anchor: bottom-left; move: right and top + newRight = startRightScreenX + screenDx; + newTop = startTopScreenY + screenDy; + } else if (corner === 'nw') { + // anchor: bottom-right; move: left and top + newLeft = startLeft + screenDx; + newTop = startTopScreenY + screenDy; + } + + // Enforce minimum screen dimensions before converting + const MIN_W_PX = 40; + const MIN_H_PX = 20; + if (newRight - newLeft < MIN_W_PX) { + if (corner === 'sw' || corner === 'nw') { + newLeft = newRight - MIN_W_PX; + } else { + newRight = newLeft + MIN_W_PX; + } + } + if (newBottom - newTop < MIN_H_PX) { + if (corner === 'ne' || corner === 'nw') { + newTop = newBottom - MIN_H_PX; + } else { + newBottom = newTop + MIN_H_PX; + } + } + + const newWidthPx = newRight - newLeft; + const newHeightPx = newBottom - newTop; const fieldEl = containerRef.current?.querySelector(`[data-field-id="${drag.fieldId}"]`); + const co = canvasOffsetRef.current; 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`; + fieldEl.style.left = `${newLeft + co.x}px`; + fieldEl.style.top = `${newTop + co.y}px`; + fieldEl.style.width = `${newWidthPx}px`; + fieldEl.style.height = `${newHeightPx}px`; } } }, []); @@ -396,11 +454,68 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla 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 corner = drag.corner ?? 'se'; + const startW = drag.startFieldW ?? 144; + const startH = drag.startFieldH ?? 36; + + // Compute start rect in screen pixels + const startLeft = (drag.startFieldX / pi.originalWidth) * renderedW; + const startBottomScreenY = renderedH - (drag.startFieldY / pi.originalHeight) * renderedH; + const startHeightPx = (startH / pi.originalHeight) * renderedH; + const startTopScreenY = startBottomScreenY - startHeightPx; + const startRightScreenX = startLeft + (startW / pi.originalWidth) * renderedW; + + let newLeft = startLeft; + let newTop = startTopScreenY; + let newRight = startRightScreenX; + let newBottom = startBottomScreenY; + + if (corner === 'se') { + newRight = startRightScreenX + screenDx; + newBottom = startBottomScreenY + screenDy; + } else if (corner === 'sw') { + newLeft = startLeft + screenDx; + newBottom = startBottomScreenY + screenDy; + } else if (corner === 'ne') { + newRight = startRightScreenX + screenDx; + newTop = startTopScreenY + screenDy; + } else if (corner === 'nw') { + newLeft = startLeft + screenDx; + newTop = startTopScreenY + screenDy; + } + + const MIN_W_PX = 40; + const MIN_H_PX = 20; + if (newRight - newLeft < MIN_W_PX) { + if (corner === 'sw' || corner === 'nw') { + newLeft = newRight - MIN_W_PX; + } else { + newRight = newLeft + MIN_W_PX; + } + } + if (newBottom - newTop < MIN_H_PX) { + if (corner === 'ne' || corner === 'nw') { + newTop = newBottom - MIN_H_PX; + } else { + newBottom = newTop + MIN_H_PX; + } + } + + const newWidthPx = newRight - newLeft; + const newHeightPx = newBottom - newTop; + + // Convert new screen rect back to PDF units + // newLeft (screen) → pdfX + const newPdfX = (newLeft / renderedW) * pi.originalWidth; + // newBottom (screen) → pdfY (bottom edge in PDF space) + // screen Y from top → PDF Y from bottom: pdfY = ((renderedH - screenY) / renderedH) * originalHeight + const newPdfY = ((renderedH - newBottom) / renderedH) * pi.originalHeight; + const newPdfW = (newWidthPx / renderedW) * pi.originalWidth; + const newPdfH = (newHeightPx / renderedH) * pi.originalHeight; + const next = currentFields.map((f) => { if (f.id !== drag.fieldId) return f; - return { ...f, width: newW, height: newH }; + return { ...f, x: newPdfX, y: newPdfY, width: newPdfW, height: newPdfH }; }); setFields(next); persistFields(docId, next); @@ -424,6 +539,42 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla const isMoving = activeDragFieldId === field.id && activeDragType === 'move'; + // Resize handle style factory + const resizeHandle = (corner: ResizeCorner) => { + const cursors: Record = { + nw: 'nw-resize', + ne: 'ne-resize', + sw: 'sw-resize', + se: 'se-resize', + }; + const pos: Record = { + nw: { top: 0, left: 0 }, + ne: { top: 0, right: 0 }, + sw: { bottom: 0, left: 0 }, + se: { bottom: 0, right: 0 }, + }; + return ( +
{ + e.stopPropagation(); + handleResizeStart(e, field.id, corner); + }} + /> + ); + }; + return (
{ + if (readOnly) 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 - - {/* Resize handle — bottom-right corner */} -
{ - e.stopPropagation(); - handleResizeStart(e, field.id); - }} - /> + {!readOnly && ( + + )} + {!readOnly && ( + <> + {resizeHandle('nw')} + {resizeHandle('ne')} + {resizeHandle('sw')} + {resizeHandle('se')} + + )}
); }); @@ -521,22 +664,24 @@ export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPla onDragStart={() => setIsDraggingToken(true)} onDragEnd={handleDragEnd} > - {/* Palette */} -
- Field Palette: - -
+ {/* Palette — hidden in read-only mode */} + {!readOnly && ( +
+ Field Palette: + +
+ )} {/* Droppable PDF zone with field overlays */}