Files
red/.planning/phases/05-pdf-fill-and-field-mapping/05-02-PLAN.md
2026-03-19 23:44:23 -06:00

16 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
05-pdf-fill-and-field-mapping 02 execute 2
05-01
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx
true
DOC-04
truths artifacts key_links
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
path provides min_lines
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx dnd-kit DndContext with draggable token palette and droppable PDF page overlay 80
path provides
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx Extended to accept and render placed fields as absolute-positioned overlays; exposes pageInfo via onPageLoad callback
from to via pattern
FieldPlacer.tsx onDragEnd screenToPdfCoords() formula inline coordinate conversion using pageContainerRef.getBoundingClientRect() renderedH - screenY.*originalHeight
from to via pattern
FieldPlacer.tsx PUT /api/documents/[id]/fields fetch PUT on every field add/remove fetch.*fields.*PUT
from to via pattern
PdfViewer.tsx Page FieldPlacer.tsx pageInfo state onLoadSuccess callback sets pageInfo: { originalWidth, originalHeight, width, height, scale } 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.

<execution_context> @/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md @/Users/ccopeland/.claude/get-shit-done/templates/summary.md </execution_context>

@.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):

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):

'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):

// 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:

// 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):

// 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 <Document>/<Page> 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<HTMLDivElement>) 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<SignatureFieldData[]>([]) loaded from server on mount

PageInfo interface (define locally in this file):

interface PageInfo {
  originalWidth: number;
  originalHeight: number;
  width: number;
  height: number;
  scale: number;
}

Structure:

'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):

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):

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:

{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 (
    <div key={field.id} style={{
      position: 'absolute',
      left,
      top: top - heightPx, // top is the y of the bottom-left corner; shift up by height for correct placement
      width: widthPx,
      height: heightPx,
      border: '2px solid #2563eb',
      background: 'rgba(37,99,235,0.1)',
      borderRadius: '2px',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'space-between',
      padding: '0 4px',
      fontSize: '10px',
      color: '#2563eb',
      pointerEvents: 'all',
    }}>
      <span>Signature</span>
      <button
        onClick={() => {
          const next = fields.filter(f => f.id !== field.id);
          setFields(next);
          persistFields(docId, next);
        }}
        style={{ cursor: 'pointer', background: 'none', border: 'none', color: '#ef4444', fontWeight: 'bold', padding: '0 2px' }}
        aria-label="Remove field"
      >×</button>
    </div>
  );
})}
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 <Page> — use only scale. Using both causes double scaling.

The final JSX structure should be:

<div className="flex flex-col items-center gap-4">
  {/* controls toolbar */}
  <div className="flex items-center gap-3 text-sm">
    {/* Prev, page counter, Next, Zoom In, Zoom Out, Download — keep existing */}
  </div>

  {/* PDF + field overlay */}
  <FieldPlacer docId={docId} pageInfo={pageInfo} currentPage={pageNumber}>
    <Document
      file={`/api/documents/${docId}/file`}
      onLoadSuccess={({ numPages }) => setNumPages(numPages)}
      className="shadow-lg"
    >
      <Page
        pageNumber={pageNumber}
        scale={scale}
        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,
          });
        }}
      />
    </Document>
  </FieldPlacer>
</div>

After modifying PdfViewer.tsx, run build to confirm no TypeScript errors:

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

<success_criteria>

  • 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 </success_criteria>
After completion, create `.planning/phases/05-pdf-fill-and-field-mapping/05-02-SUMMARY.md`