diff --git a/teressa-copeland-homes/package-lock.json b/teressa-copeland-homes/package-lock.json index a268281..dccdbf3 100644 --- a/teressa-copeland-homes/package-lock.json +++ b/teressa-copeland-homes/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "dependencies": { "@cantoo/pdf-lib": "^2.6.3", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/utilities": "^3.2.2", "@vercel/blob": "^2.3.1", "adm-zip": "^0.5.16", "bcryptjs": "^3.0.3", @@ -597,6 +599,45 @@ "tslib": ">=2" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@drizzle-team/brocli": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", diff --git a/teressa-copeland-homes/package.json b/teressa-copeland-homes/package.json index 384b587..fdfc4bb 100644 --- a/teressa-copeland-homes/package.json +++ b/teressa-copeland-homes/package.json @@ -16,6 +16,8 @@ }, "dependencies": { "@cantoo/pdf-lib": "^2.6.3", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/utilities": "^3.2.2", "@vercel/blob": "^2.3.1", "adm-zip": "^0.5.16", "bcryptjs": "^3.0.3", 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 new file mode 100644 index 0000000..a640a8d --- /dev/null +++ b/teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx @@ -0,0 +1,321 @@ +'use client'; +import { useState, useEffect, useRef, useCallback } from 'react'; +import { + DndContext, + useDraggable, + useDroppable, + DragOverlay, + type DragEndEvent, +} from '@dnd-kit/core'; +import { CSS } from '@dnd-kit/utilities'; +import type { SignatureFieldData } from '@/lib/db/schema'; + +interface PageInfo { + originalWidth: number; + originalHeight: number; + width: number; + height: number; + scale: number; +} + +// Screen (DOM) → PDF user space. Y-axis flip required. +// DOM: Y=0 at top, increases downward +// PDF: Y=0 at bottom, increases upward +function screenToPdfCoords( + screenX: number, + screenY: number, + containerRect: DOMRect, + pageInfo: PageInfo, +) { + // Use containerRect dimensions (current rendered size) not stale pageInfo + const renderedW = containerRect.width; + const renderedH = containerRect.height; + const pdfX = (screenX / renderedW) * pageInfo.originalWidth; + const pdfY = ((renderedH - screenY) / renderedH) * pageInfo.originalHeight; + return { x: pdfX, y: pdfY }; +} + +// PDF user space → screen (for rendering stored fields) +function pdfToScreenCoords( + pdfX: number, + pdfY: number, + containerRect: DOMRect, + pageInfo: PageInfo, +) { + const renderedW = containerRect.width; + const renderedH = containerRect.height; + const left = (pdfX / pageInfo.originalWidth) * renderedW; + // top is measured from DOM top; pdfY is from PDF bottom — reverse flip + const top = renderedH - (pdfY / pageInfo.originalHeight) * renderedH; + return { left, top }; +} + +async function persistFields(docId: string, fields: SignatureFieldData[]) { + try { + await fetch(`/api/documents/${docId}/fields`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fields), + }); + } catch (e) { + console.error('Failed to persist fields:', e); + } +} + +// Draggable token in the palette +function DraggableToken({ id }: { id: string }) { + const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id }); + const style: React.CSSProperties = { + transform: CSS.Translate.toString(transform), + opacity: isDragging ? 0.4 : 1, + cursor: 'grab', + padding: '6px 12px', + border: '2px dashed #2563eb', + borderRadius: '4px', + background: 'rgba(37,99,235,0.08)', + color: '#2563eb', + fontSize: '13px', + fontWeight: 600, + userSelect: 'none', + touchAction: 'none', + }; + + return ( +
+ + Signature Field +
+ ); +} + +// Droppable zone wrapper for the PDF canvas +function DroppableZone({ + id, + containerRef, + children, +}: { + id: string; + containerRef: React.RefObject; + children: React.ReactNode; +}) { + const { setNodeRef } = useDroppable({ id }); + + return ( +
{ + setNodeRef(node); + if (containerRef && 'current' in containerRef) { + (containerRef as React.MutableRefObject).current = node; + } + }} + style={{ position: 'relative' }} + > + {children} +
+ ); +} + +interface FieldPlacerProps { + docId: string; + pageInfo: PageInfo | null; + currentPage: number; + children: React.ReactNode; +} + +export function FieldPlacer({ docId, pageInfo, currentPage, children }: FieldPlacerProps) { + const [fields, setFields] = useState([]); + const [isDraggingToken, setIsDraggingToken] = useState(false); + const containerRef = useRef(null); + + // Load existing fields from server on mount + useEffect(() => { + async function loadFields() { + try { + const res = await fetch(`/api/documents/${docId}/fields`); + if (res.ok) { + const data = await res.json(); + setFields(data); + } + } catch (e) { + console.error('Failed to load fields:', e); + } + } + loadFields(); + }, [docId]); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + setIsDraggingToken(false); + + const { active, over, activatorEvent, delta } = event; + + // Only process if dropped onto the PDF zone + if (!over || over.id !== 'pdf-drop-zone') return; + if (!pageInfo) return; + + const containerRect = containerRef.current?.getBoundingClientRect(); + if (!containerRect) return; + + // 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; + + 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 + containerRect.width / 2; + clientY = containerRect.top + containerRect.height / 2; + } + + // finalClient = activatorClient + delta (displacement during drag) + const finalClientX = clientX + delta.x; + const finalClientY = clientY + delta.y; + + // Convert to coordinates relative to the container + const screenX = finalClientX - containerRect.left; + const screenY = finalClientY - containerRect.top; + + // Clamp to container bounds + const clampedX = Math.max(0, Math.min(screenX, containerRect.width)); + const clampedY = Math.max(0, Math.min(screenY, containerRect.height)); + + // Convert screen coords to PDF user-space (Y-flip applied) + const { x: pdfX, y: pdfY } = screenToPdfCoords(clampedX, clampedY, containerRect, pageInfo); + + 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 + }; + + const next = [...fields, newField]; + setFields(next); + persistFields(docId, next); + }, + [fields, pageInfo, currentPage, docId], + ); + + // Render placed fields for the current page + const renderFields = () => { + if (!pageInfo) return null; + const containerRect = containerRef.current?.getBoundingClientRect(); + if (!containerRect) return null; + + return fields + .filter((f) => f.page === currentPage) + .map((field) => { + const { left, top } = pdfToScreenCoords(field.x, field.y, containerRect, pageInfo); + const widthPx = (field.width / pageInfo.originalWidth) * containerRect.width; + const heightPx = (field.height / pageInfo.originalHeight) * containerRect.height; + + return ( +
+ Signature + +
+ ); + }); + }; + + return ( + setIsDraggingToken(true)} + onDragEnd={handleDragEnd} + > + {/* Palette */} +
+ Field Palette: + +
+ + {/* Droppable PDF zone with field overlays */} + + {children} + {renderFields()} + + + {/* Ghost overlay during drag */} + + {isDraggingToken ? ( +
+ Signature +
+ ) : null} +
+
+ ); +}