16 KiB
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 |
|
|
true |
|
|
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.mdFrom 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).
Step B — Create FieldPlacer.tsx.
This is a client component. It:
- Fetches existing fields from GET /api/documents/[id]/fields on mount
- Renders a palette with a single draggable "Signature Field" token using
useDraggable - Renders the PDF page container as a
useDroppablezone - 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
- Renders placed fields as absolute-positioned divs over the PDF page (using pdfToScreenCoords)
- 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 }wherechildrenis the<Document>/<Page>tree rendered by PdfViewer - The droppable zone wraps the children (PDF canvas) with
position: relativeso overlays position correctly - Default field size: 144 × 36 PDF points (2 inches × 0.5 inches at 72 DPI)
- Use
DragOverlayto show a ghost during drag (better UX than transform-based dragging) - The page container ref (
useRef<HTMLDivElement>) is attached to the droppable wrapper div — usegetBoundingClientRect()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>
);
})}
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
<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>