Files

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
teressa-copeland-homes/src/lib/pdf/prepare-document.ts
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx
true
TXTF-01
TXTF-03
truths artifacts key_links
preparePdf draws text at the correct field-box position using the UUID field ID as the lookup key — not positional index
Strategy B top-of-page fallback stamping is removed — no UUID strings appear at the top of page 1
FieldPlacer accepts selectedFieldId, textFillData, onFieldSelect, onFieldValueChange as optional props without TypeScript errors
PdfViewer and PdfViewerWrapper compile cleanly with the new optional props threaded through
Clicking a text field box on the PDF selects it and renders an inline input
The inline input does not trigger the move/drag handler (data-no-move + stopPropagation)
Clicking a non-text field or the PDF background deselects the current field
path provides contains
teressa-copeland-homes/src/lib/pdf/prepare-document.ts Field-ID-keyed text drawing; Strategy B and positional loop removed textFields[field.id]
path provides exports
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx Per-field click-to-select + inline input overlay for text fields
FieldPlacer
path provides
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx Optional prop forwarding to FieldPlacer
path provides
teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx Optional prop forwarding to PdfViewer
from to via pattern
DocumentPageClient.tsx (Plan 02) PdfViewerWrapper.tsx selectedFieldId, textFillData, onFieldSelect, onFieldValueChange optional props selectedFieldId?
from to via pattern
FieldPlacer.tsx renderFields() text field box inline input onClick calls onFieldSelect?(field.id); input onChange calls onFieldValueChange?(field.id, value) onFieldSelect?.
from to via pattern
prepare-document.ts field-ID lookup textFields[field.id] direct lookup textFields[field.id]
Replace the broken positional text fill pipeline with a field-ID-keyed approach, and add the per-field click-to-select + inline input interaction to FieldPlacer.

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.md

From 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`:
  1. Remove the positional block (lines ~47-112): Delete the entire section that includes:

    • acroFilledKeys Set declaration and population
    • remainingEntries array construction
    • textFields_sorted sort
    • fieldConsumedKeys Set
    • textFields_sorted.forEach(...) loop that draws text at field positions
  2. Remove Strategy A (AcroForm filling, lines ~52-69): Remove the try/catch block that calls pdfDoc.getForm(), fills AcroForm fields, and calls form.flatten(). Also remove hasAcroForm variable. The AcroForm strategy is no longer needed — text values are always drawn at field box positions.

  3. Remove Strategy B (lines ~118-147): Delete the unstampedEntries block that stamps key/value pairs at the top of page 1.

  4. 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),
      });
    }
    
  5. 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), the text case 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.

  6. 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.
    
  7. Remove acroFilledKeys, hasAcroForm, remainingEntries, fieldConsumedKeys, textFields_sorted variables 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

Task 2: Add optional text-edit props to PdfViewerWrapper, PdfViewer, and FieldPlacer click-to-select teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewerWrapper.tsx teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/FieldPlacer.tsx **PdfViewerWrapper.tsx** — Add 4 optional props and forward to PdfViewer: ```typescript export function PdfViewerWrapper({ docId, docStatus, onFieldsChanged, selectedFieldId, textFillData, onFieldSelect, onFieldValueChange, }: { docId: string; docStatus?: string; onFieldsChanged?: () => void; selectedFieldId?: string | null; textFillData?: Record; onFieldSelect?: (fieldId: string | null) => void; onFieldValueChange?: (fieldId: string, value: string) => void; }) { return ( ); } ```

PdfViewer.tsx — Same 4 optional props; forward to FieldPlacer; clear selectedFieldId on page change:

  1. Add the same 4 optional props to the PdfViewer function signature.
  2. 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}
    >
    
  3. In the Prev/Next page buttons' onClick handlers, also call onFieldSelect?.(null) to deselect when the agent navigates pages. The next button becomes:
    onClick={() => { setPageNumber(p => Math.min(numPages, p + 1)); onFieldSelect?.(null); }}
    
    And prev button:
    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:

  1. Extend FieldPlacerProps interface:

    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;
    }
    
  2. Destructure the 4 new props in the function signature alongside the existing props.

  3. In DroppableZone, add an onClick prop 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 DroppableZone component definition (lines 120-150) to accept and spread an onClick prop:

    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>
      );
    }
    
  4. In renderFields(), change the text field rendering. Currently text fields use the same div as other field types. Replace the entire per-field div for text type fields with this pattern (inside the .map() callback in renderFields()):

    Add these variables at the top of the .map() callback (alongside existing isMoving, 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>
      )
    )}
    
  5. Add an onClick handler to the per-field div (alongside onPointerDown) 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 onPointerDown handler is NOT removed — move/resize still uses it. onClick fires after pointerdown+pointerup with no movement (< 5px — MouseSensor { distance: 5 } threshold ensures drags don't trigger onClick).

  6. Update the field box div's cursor style to reflect selection state for text fields:

    cursor: readOnly ? 'default' : (isMoving ? 'grabbing' : (fieldType === 'text' ? 'text' : 'grab')),
    
  7. 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),
    
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30 - PdfViewerWrapper, PdfViewer, FieldPlacer all compile with zero TypeScript errors - FieldPlacerProps has selectedFieldId?, textFillData?, onFieldSelect?, onFieldValueChange? optional props - DroppableZone has onClick prop accepted and applied - Text field boxes in renderFields() show inline input when isSelected === true - Non-text field onClick calls onFieldSelect?.(null) - npx tsc --noEmit passes with zero errors After both tasks complete: - `npx tsc --noEmit` in teressa-copeland-homes passes with zero errors - `prepare-document.ts` contains `textFields[field.id]` and does NOT contain `remainingEntries`, `unstampedEntries`, `fieldConsumedKeys`, `getForm()`, or `form.flatten()` - `FieldPlacer.tsx` exports `FieldPlacer` with the 4 new optional props in its interface - `PdfViewerWrapper.tsx` and `PdfViewer.tsx` accept and forward the 4 new optional props

<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>
After completion, create `.planning/phases/12.1-per-field-text-editing-and-quick-fill/12.1-01-SUMMARY.md`