--- phase: 05-pdf-fill-and-field-mapping plan: 02 type: execute wave: 2 depends_on: [05-01] files_modified: - teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx - teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx autonomous: true requirements: [DOC-04] must_haves: truths: - "Agent sees a field palette on the document detail page with a draggable Signature Field token" - "Agent can drag the Signature Field token onto any part of any PDF page and release it to place a field" - "Placed fields appear as blue-bordered semi-transparent rectangles overlaid on the correct position of the PDF page" - "Stored fields persist across page reload (coordinates are saved to the server via PUT /api/documents/[id]/fields)" - "Fields placed at the top of a page have high PDF Y values (near originalHeight); fields placed at the bottom have low PDF Y values — Y-axis flip is correct" - "Placed fields can be deleted individually via a remove button" artifacts: - path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx" provides: "dnd-kit DndContext with draggable token palette and droppable PDF page overlay" min_lines: 80 - path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx" provides: "Extended to accept and render placed fields as absolute-positioned overlays; exposes pageInfo via onPageLoad callback" key_links: - from: "FieldPlacer.tsx onDragEnd" to: "screenToPdfCoords() formula" via: "inline coordinate conversion using pageContainerRef.getBoundingClientRect()" pattern: "renderedH - screenY.*originalHeight" - from: "FieldPlacer.tsx" to: "PUT /api/documents/[id]/fields" via: "fetch PUT on every field add/remove" pattern: "fetch.*fields.*PUT" - from: "PdfViewer.tsx Page" to: "FieldPlacer.tsx pageInfo state" via: "onLoadSuccess callback sets pageInfo: { originalWidth, originalHeight, width, height, scale }" pattern: "onLoadSuccess.*originalWidth" --- Extend the document detail page with a drag-and-drop field placer. The agent drags a Signature Field token from a palette onto any PDF page. On drop, screen coordinates are converted to PDF user-space coordinates (Y-axis flip formula), stored in state and persisted via PUT /api/documents/[id]/fields. Placed fields are rendered as blue rectangle overlays on the PDF. Fields load from the server on mount. Purpose: Fulfills DOC-04 — agent can place signature fields on any page of a PDF and those coordinates survive for downstream PDF preparation and signing. Output: FieldPlacer.tsx client component + extended PdfViewer.tsx. @/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md @/Users/ccopeland/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/STATE.md @.planning/phases/05-pdf-fill-and-field-mapping/05-RESEARCH.md @.planning/phases/05-pdf-fill-and-field-mapping/05-01-SUMMARY.md From teressa-copeland-homes/src/lib/db/schema.ts (after Plan 01): ```typescript export interface SignatureFieldData { id: string; page: number; // 1-indexed x: number; // PDF user space, bottom-left origin, points y: number; // PDF user space, bottom-left origin, points width: number; // PDF points height: number; // PDF points } ``` From teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx (current — MODIFY): ```typescript 'use client'; import { useState } from 'react'; import { Document, Page, pdfjs } from 'react-pdf'; import 'react-pdf/dist/Page/AnnotationLayer.css'; import 'react-pdf/dist/Page/TextLayer.css'; pdfjs.GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url, ).toString(); export function PdfViewer({ docId }: { docId: string }) { const [numPages, setNumPages] = useState(0); const [pageNumber, setPageNumber] = useState(1); const [scale, setScale] = useState(1.0); // ...renders Document + Page with Prev/Next, Zoom In/Out, Download } ``` API contract (from Plan 01): - GET /api/documents/[id]/fields → SignatureFieldData[] (returns [] if none) - PUT /api/documents/[id]/fields → body: SignatureFieldData[] → returns updated array Coordinate conversion (CRITICAL — do not re-derive): ```typescript // 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, pageInfo: PageInfo) { const pdfX = (screenX / pageInfo.width) * pageInfo.originalWidth; const pdfY = ((pageInfo.height - screenY) / pageInfo.height) * pageInfo.originalHeight; return { x: pdfX, y: pdfY }; } // PDF user space → screen (for rendering stored fields) function pdfToScreenCoords(pdfX: number, pdfY: number, pageInfo: PageInfo) { const left = (pdfX / pageInfo.originalWidth) * pageInfo.width; // top is measured from DOM top; pdfY is from PDF bottom — reverse flip const top = pageInfo.height - (pdfY / pageInfo.originalHeight) * pageInfo.height; return { left, top }; } ``` Pitfall guard for originalHeight: ```typescript // Some PDFs have non-standard mediaBox ordering — use Math.max to handle both originalWidth: Math.max(page.view[0], page.view[2]), originalHeight: Math.max(page.view[1], page.view[3]), ``` dnd-kit drop position pattern (from research — MEDIUM confidence, verify during impl): ```typescript // The draggable token is in the palette, not on the canvas. // Use DragOverlay for visual ghost + compute final position from // the mouse/touch coordinates at drop time relative to the container rect. // event.delta gives displacement from drag start position of the activator. // For an item dragged FROM the palette onto the PDF zone: // finalX = activatorClientX + event.delta.x - containerRect.left // finalY = activatorClientY + event.delta.y - containerRect.top // The activator coordinates come from event.activatorEvent (MouseEvent or TouchEvent). ``` Task 1: Install dnd-kit and create FieldPlacer component teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx **Step A — Install dnd-kit:** ```bash cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm install @dnd-kit/core @dnd-kit/utilities ``` **Step B — Create FieldPlacer.tsx.** This is a client component. It: 1. Fetches existing fields from GET /api/documents/[id]/fields on mount 2. Renders a palette with a single draggable "Signature Field" token using `useDraggable` 3. Renders the PDF page container as a `useDroppable` zone 4. On drop, converts screen coordinates to PDF user-space using the Y-flip formula, adds the new field to state, persists via PUT /api/documents/[id]/fields 5. Renders placed fields as absolute-positioned divs over the PDF page (using pdfToScreenCoords) 6. Each placed field has an X button to delete it (removes from state, persists) Key implementation details: - Accept props: `{ docId: string; pageInfo: PageInfo | null; currentPage: number; children: React.ReactNode }` where `children` is the `/` tree rendered by PdfViewer - The droppable zone wraps the children (PDF canvas) with `position: relative` so overlays position correctly - Default field size: 144 × 36 PDF points (2 inches × 0.5 inches at 72 DPI) - Use `DragOverlay` to show a ghost during drag (better UX than transform-based dragging) - The page container ref (`useRef`) is attached to the droppable wrapper div — use `getBoundingClientRect()` at drop time for current rendered dimensions (NOT stale pageInfo.width because zoom may have changed) - Persist via async fetch (fire-and-forget with error logging — don't block UI) - Fields state: `useState([])` loaded from server on mount PageInfo interface (define locally in this file): ```typescript interface PageInfo { originalWidth: number; originalHeight: number; width: number; height: number; scale: number; } ``` Structure: ```typescript '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'; // ... DraggableToken sub-component using useDraggable // ... FieldPlacer main component ``` Coordinate math — use EXACTLY this formula (do not re-derive): ```typescript 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 }; } function pdfToScreenCoords(pdfX: number, pdfY: number, containerRect: DOMRect, pageInfo: PageInfo) { const renderedW = containerRect.width; const renderedH = containerRect.height; const left = (pdfX / pageInfo.originalWidth) * renderedW; const top = renderedH - (pdfY / pageInfo.originalHeight) * renderedH; return { left, top }; } ``` Persist helper (keep outside component to avoid re-creation): ```typescript 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); } } ``` Field overlay rendering — subtract height because PDF Y is at bottom-left, but DOM top is at top-left: ```typescript {fields.filter(f => f.page === currentPage).map(field => { const containerRect = containerRef.current?.getBoundingClientRect(); if (!containerRect || !pageInfo) return null; 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
); })} ```
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "(error TS|Error:|compiled successfully|Build)" | head -10 - FieldPlacer.tsx exists and exports FieldPlacer component - @dnd-kit/core and @dnd-kit/utilities in package.json - Component accepts docId, pageInfo, currentPage, children props - Uses exactly the Y-flip formula from research (no re-derivation) - npm run build compiles without TypeScript errors
Task 2: Extend PdfViewer to expose pageInfo and integrate FieldPlacer teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx Rewrite PdfViewer.tsx to: 1. Add `pageInfo` state (`useState(null)`) where PageInfo is `{ originalWidth, originalHeight, width, height, scale }` 2. Update the `` component's `onLoadSuccess` callback to set pageInfo using the `Math.max` pattern for originalWidth/Height: ```typescript onLoadSuccess={(page) => { setPageInfo({ originalWidth: Math.max(page.view[0], page.view[2]), originalHeight: Math.max(page.view[1], page.view[3]), width: page.width, height: page.height, scale: page.scale, }); }} ``` 3. Import and wrap the `` tree inside ``. FieldPlacer renders children (the PDF canvas) plus the droppable zone + field overlays. 4. Add a `docId` prop to PdfViewer: `{ docId: string }` (already exists — no change needed) 5. Keep all existing controls (Prev, Next, Zoom In, Zoom Out, Download) unchanged Note on PdfViewerWrapper: PdfViewerWrapper.tsx (dynamic import wrapper) does NOT need to change — it already passes `docId` through to PdfViewer. Note on `page.view`: For react-pdf v10 (installed), the `Page.onLoadSuccess` callback receives a `page` object where `page.view` is `[x1, y1, x2, y2]`. For standard US Letter PDFs this is `[0, 0, 612, 792]`. The `Math.max` pattern handles non-standard mediaBox ordering. Do NOT use both `width` and `scale` props on `` — use only `scale`. Using both causes double scaling. The final JSX structure should be: ```tsx
{/* controls toolbar */}
{/* Prev, page counter, Next, Zoom In, Zoom Out, Download — keep existing */}
{/* PDF + field overlay */} setNumPages(numPages)} className="shadow-lg" > { setPageInfo({ originalWidth: Math.max(page.view[0], page.view[2]), originalHeight: Math.max(page.view[1], page.view[3]), width: page.width, height: page.height, scale: page.scale, }); }} />
``` After modifying PdfViewer.tsx, run build to confirm no TypeScript errors: ```bash cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -15 ```
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "(error TS|Error:|compiled successfully|Build)" | head -10 - PdfViewer.tsx has pageInfo state with Math.max mediaBox pattern on onLoadSuccess - FieldPlacer is imported and wraps the Document/Page tree - Only scale prop used on Page (not both width + scale) - npm run build compiles without TypeScript errors - PdfViewerWrapper.tsx unchanged
After both tasks complete: 1. `npm run build` in teressa-copeland-homes — clean compile 2. Run `npm run dev` and navigate to any document detail page 3. A "Signature Field" draggable token appears in a palette area above or beside the PDF 4. Drag the token onto the PDF page — a blue rectangle appears at the drop location 5. Refresh the page — the blue rectangle is still there (persisted to DB) 6. Click the × button on a placed field — it disappears from the overlay and from DB 7. Navigate to page 2 of a multi-page document — fields placed on page 1 don't appear on page 2 - Agent can drag a Signature Field token onto any PDF page and see a blue rectangle overlay at the correct position - Coordinates stored in PDF user space (Y-flip applied) — placing a field at visual top of page stores high pdfY value - Fields persist across page reload (PUT /api/documents/[id]/fields called on every change) - Fields are page-scoped (field.page === currentPage filter applied) - npm run build is clean After completion, create `.planning/phases/05-pdf-fill-and-field-mapping/05-02-SUMMARY.md`