18 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 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 12.1-per-field-text-editing-and-quick-fill | 01 | execute | 1 |
|
true |
|
|
Purpose: The current preparePdf() assigns textFillData values positionally (sort by page/y, match by index), which breaks when field order or count doesn't align with the textFillData entries. Strategy B then stamps UUID keys as visible text at the top of page 1. This plan fixes both issues at the infrastructure and interaction layers.
Output:
- prepare-document.ts: direct field-ID lookup replaces positional loop; Strategy B removed
- FieldPlacer.tsx: text fields render inline input when selected; DroppableZone deselects on background click
- PdfViewer.tsx + PdfViewerWrapper.tsx: optional prop chain threaded through so Plan 02 can wire them
<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/ROADMAP.md @.planning/phases/12.1-per-field-text-editing-and-quick-fill/12.1-RESEARCH.md @.planning/phases/12-filled-document-preview/12-02-SUMMARY.mdFrom src/lib/pdf/prepare-document.ts (current):
export async function preparePdf(
srcPath: string,
destPath: string,
textFields: Record<string, string>, // currently keyed by label — changing to fieldId
sigFields: SignatureFieldData[],
agentSignatureData: string | null = null,
agentInitialsData: string | null = null,
): Promise<void>
Current broken positional block (lines 77-112) to REMOVE:
const remainingEntries: Array<[string, string]> = Object.entries(textFields).filter(...)
const textFields_sorted = sigFields.filter(f => getFieldType(f) === 'text').sort(...)
const fieldConsumedKeys = new Set<string>();
textFields_sorted.forEach((field, idx) => { const entry = remainingEntries[idx]; ... });
Current Strategy B block (lines 118-147) to REMOVE:
const unstampedEntries = remainingEntries.filter(([key]) => !fieldConsumedKeys.has(key));
if (unstampedEntries.length > 0) { ... firstPage.drawText(`${key}: ${value}`, ...) }
From src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx (current):
interface FieldPlacerProps {
docId: string;
pageInfo: PageInfo | null;
currentPage: number;
children: React.ReactNode;
readOnly?: boolean;
onFieldsChanged?: () => void;
}
DroppableZone currently at line 726 — needs onClick handler added. renderFields() per-field div at line 612 — text fields need click-to-select rendering.
From src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx (current):
export function PdfViewer({ docId, docStatus, onFieldsChanged }: { docId: string; docStatus?: string; onFieldsChanged?: () => void })
FieldPlacer render at line 72 — needs new optional props forwarded.
From src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx (current):
export function PdfViewerWrapper({ docId, docStatus, onFieldsChanged }: { docId: string; docStatus?: string; onFieldsChanged?: () => void })
Wraps PdfViewer via Next.js dynamic() — needs 4 new optional props added and forwarded.
Task 1: Replace positional text fill with field-ID-keyed lookup in preparePdf teressa-copeland-homes/src/lib/pdf/prepare-document.ts In `teressa-copeland-homes/src/lib/pdf/prepare-document.ts`:-
Remove the positional block (lines ~47-112): Delete the entire section that includes:
acroFilledKeysSet declaration and populationremainingEntriesarray constructiontextFields_sortedsortfieldConsumedKeysSettextFields_sorted.forEach(...)loop that draws text at field positions
-
Remove Strategy A (AcroForm filling, lines ~52-69): Remove the try/catch block that calls
pdfDoc.getForm(), fills AcroForm fields, and callsform.flatten(). Also removehasAcroFormvariable. The AcroForm strategy is no longer needed — text values are always drawn at field box positions. -
Remove Strategy B (lines ~118-147): Delete the
unstampedEntriesblock that stamps key/value pairs at the top of page 1. -
Add the new field-ID-keyed drawing loop in their place, before the existing
for (const field of sigFields)loop that handles non-text field types. Insert:// Phase 12.1: Draw text values at placed text field boxes — keyed by field ID (UUID) for (const field of sigFields) { if (getFieldType(field) !== 'text') continue; const value = textFields[field.id]; if (!value) continue; const page = pages[field.page - 1]; if (!page) continue; const fontSize = Math.max(6, Math.min(11, field.height - 4)); page.drawText(value, { x: field.x + 4, y: field.y + 4, size: fontSize, font: helvetica, color: rgb(0.05, 0.05, 0.05), }); } -
In the field-type dispatch loop (the existing
for (const field of sigFields)block that renders client-signature, initials, checkbox, date, agent-signature, agent-initials), thetextcase currently says "Text value drawn above". Keep that comment as-is (or update to "Drawn in field-ID loop above") — no additional drawing needed there. -
Update the JSDoc comment at the top of preparePdf to remove the reference to AcroForm Strategy A and Strategy B. Replace with:
* Text fill: textFields is keyed by SignatureFieldData.id (UUID). Each text-type * placed field box has its value looked up directly by field ID and drawn at * the field's coordinates. -
Remove
acroFilledKeys,hasAcroForm,remainingEntries,fieldConsumedKeys,textFields_sortedvariables since none exist in the new code.
Note: The textFields parameter type is still Record<string, string> — no signature change needed. The calling routes pass body.textFillData directly, which will now be { [fieldId]: value } once Plan 02 wires the UI. The function signature remains backward-compatible.
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30
- textFields[field.id] lookup exists in prepare-document.ts
- No remainingEntries, fieldConsumedKeys, textFields_sorted, or unstampedEntries variables remain
- No AcroForm getForm()/flatten() calls remain
- No Strategy B drawText at top of page 1 remains
- npx tsc --noEmit passes with zero errors
PdfViewer.tsx — Same 4 optional props; forward to FieldPlacer; clear selectedFieldId on page change:
- Add the same 4 optional props to the PdfViewer function signature.
- In the
<FieldPlacer>render (line 72), add the 4 new props:<FieldPlacer docId={docId} pageInfo={pageInfo} currentPage={pageNumber} readOnly={readOnly} onFieldsChanged={onFieldsChanged} selectedFieldId={selectedFieldId} textFillData={textFillData} onFieldSelect={onFieldSelect} onFieldValueChange={onFieldValueChange} > - In the Prev/Next page buttons' onClick handlers, also call
onFieldSelect?.(null)to deselect when the agent navigates pages. The next button becomes:And prev button:onClick={() => { setPageNumber(p => Math.min(numPages, p + 1)); onFieldSelect?.(null); }}onClick={() => { setPageNumber(p => Math.max(1, p - 1)); onFieldSelect?.(null); }}
FieldPlacer.tsx — Add 4 optional props; wire click-to-select on text fields; deselect on background click:
-
Extend
FieldPlacerPropsinterface:interface FieldPlacerProps { docId: string; pageInfo: PageInfo | null; currentPage: number; children: React.ReactNode; readOnly?: boolean; onFieldsChanged?: () => void; selectedFieldId?: string | null; textFillData?: Record<string, string>; onFieldSelect?: (fieldId: string | null) => void; onFieldValueChange?: (fieldId: string, value: string) => void; } -
Destructure the 4 new props in the function signature alongside the existing props.
-
In
DroppableZone, add anonClickprop that deselects when the agent clicks the PDF background (not a field):<DroppableZone id="pdf-drop-zone" containerRef={containerRef} onZonePointerMove={handleZonePointerMove} onZonePointerUp={handleZonePointerUp} onClick={(e) => { // Deselect if clicking the zone background (not a field box) if (!(e.target as HTMLElement).closest('[data-field-id]')) { onFieldSelect?.(null); } }} >Also update the
DroppableZonecomponent definition (lines 120-150) to accept and spread anonClickprop:function DroppableZone({ id, containerRef, children, onZonePointerMove, onZonePointerUp, onClick, }: { id: string; containerRef: React.RefObject<HTMLDivElement | null>; children: React.ReactNode; onZonePointerMove: (e: React.PointerEvent<HTMLDivElement>) => void; onZonePointerUp: (e: React.PointerEvent<HTMLDivElement>) => void; onClick?: (e: React.MouseEvent<HTMLDivElement>) => void; }) { // ...existing body, add onClick to the div: return ( <div ref={...} style={{ position: 'relative' }} onPointerMove={onZonePointerMove} onPointerUp={onZonePointerUp} onClick={onClick} > {children} </div> ); } -
In
renderFields(), change the text field rendering. Currently text fields use the same div as other field types. Replace the entire per-field div fortexttype fields with this pattern (inside the.map()callback inrenderFields()):Add these variables at the top of the
.map()callback (alongside existingisMoving,fieldType, etc.):const isSelected = selectedFieldId === field.id; const currentValue = textFillData?.[field.id] ?? '';Then, inside the field box div, replace the current content:
// Current (for all types): {fieldType !== 'checkbox' && ( <span style={{ pointerEvents: 'none' }}>{fieldLabel}</span> )}With a conditional that handles text differently:
{fieldType === 'text' ? ( isSelected ? ( <input data-no-move autoFocus value={currentValue} onChange={(e) => onFieldValueChange?.(field.id, e.target.value)} onPointerDown={(e) => e.stopPropagation()} style={{ flex: 1, background: 'transparent', border: 'none', outline: 'none', fontSize: '10px', color: fieldColor, width: '100%', cursor: 'text', padding: 0, }} placeholder="Type value..." /> ) : ( <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', pointerEvents: 'none' }}> {currentValue || fieldLabel} </span> ) ) : ( fieldType !== 'checkbox' && ( <span style={{ pointerEvents: 'none' }}>{fieldLabel}</span> ) )} -
Add an
onClickhandler to the per-field div (alongsideonPointerDown) that fires for text field selection:onClick={(e) => { if (readOnly) return; if (getFieldType(field) === 'text') { e.stopPropagation(); // prevent DroppableZone's deselect handler from firing onFieldSelect?.(field.id); } else { // Non-text field click: deselect any selected text field onFieldSelect?.(null); } }}Important: The existing
onPointerDownhandler is NOT removed — move/resize still uses it.onClickfires after pointerdown+pointerup with no movement (< 5px — MouseSensor{ distance: 5 }threshold ensures drags don't trigger onClick). -
Update the field box div's
cursorstyle to reflect selection state for text fields:cursor: readOnly ? 'default' : (isMoving ? 'grabbing' : (fieldType === 'text' ? 'text' : 'grab')), -
Update the border style to highlight selected text fields:
border: `2px solid ${isSelected && fieldType === 'text' ? fieldColor : fieldColor}`, // Also thicken border when selected: border: isSelected && fieldType === 'text' ? `2px solid ${fieldColor}` : `2px solid ${fieldColor}`, // Add a box shadow for selected state: boxShadow: isSelected && fieldType === 'text' ? `0 0 0 2px ${fieldColor}66, ${isMoving ? `0 4px 12px ${fieldColor}59` : ''}` : (isMoving ? `0 4px 12px ${fieldColor}59` : undefined),
<success_criteria>
- preparePdf draws text at field-ID-keyed positions — no positional assignment, no Strategy B stamps
- FieldPlacer text fields show inline input on click (isSelected state); show value or label otherwise
- Prop chain complete from PdfViewerWrapper through PdfViewer to FieldPlacer — Plan 02 can wire state from DocumentPageClient without any additional changes to these files
- TypeScript: zero errors </success_criteria>