'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Document, Page, pdfjs } from 'react-pdf'; import { ChevronLeft, ChevronRight, Eye, Loader2, Save, Sparkles, Trash2, Upload, X, } from 'lucide-react'; import 'react-pdf/dist/Page/AnnotationLayer.css'; import 'react-pdf/dist/Page/TextLayer.css'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import { PageHeader } from '@/components/shared/page-header'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; import { VALID_MERGE_TOKENS } from '@/lib/templates/merge-fields'; import type { FieldMap, FieldMapEntry } from '@/lib/templates/field-map'; // Worker setup mirrors src/components/files/pdf-viewer.tsx so we don't // pay for a second bundle. Calling GlobalWorkerOptions.workerSrc twice // with the same URL is a no-op in pdfjs. pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; interface TemplateData { id: string; name: string; templateType: string; templateFormat: string; sourceFileId: string | null; overlayPositions: FieldMap | null; /** Tokens marked as required for the EOI flow - see * STANDARD_EOI_MERGE_FIELDS in lib/templates/merge-fields. The editor * surfaces a checklist of which required tokens are still unplaced. */ mergeFields?: string[] | null; } interface PendingMarker { x: number; y: number; page: number; } type DragKind = 'move' | 'resize-nw' | 'resize-ne' | 'resize-sw' | 'resize-se'; interface DragState { index: number; kind: DragKind; startMarkerX: number; startMarkerY: number; startMarkerW: number; startMarkerH: number; startClientX: number; startClientY: number; containerW: number; containerH: number; } const TOKEN_OPTIONS = Array.from(VALID_MERGE_TOKENS).sort(); const DEFAULT_MARKER_W = 0.18; const DEFAULT_MARKER_H = 0.04; const MIN_MARKER_DIM = 0.02; /** * Phase 7.1 + 7.2 - PDF marker editor. * * - Click anywhere to drop a marker (page-aware). * - Drag markers to move; corner handles to resize. * - Right-click for context-menu delete. * - Multi-page navigation via page picker. * - "Required tokens unplaced" checklist surfaces missing fields. * - Unsaved-changes guard (beforeunload + visual diff indicator). * - Responsive PDF width tracks the container via ResizeObserver. * - Live preview pane renders the AcroForm fill against a chosen * interest via POST /api/v1/document-templates/[id]/preview. * - "Replace PDF" reuses the existing template-templates source-file * upload route while preserving the field map (warn on page-count * change). */ export function TemplateEditor({ templateId }: { templateId: string }) { const { data: template, isLoading } = useQuery<{ data: TemplateData }>({ queryKey: ['document-template', templateId], queryFn: () => apiFetch<{ data: TemplateData }>(`/api/v1/document-templates/${templateId}`), }); if (isLoading || !template) { return (
Loading template…
); } // Inner body keyed by templateId so a route change re-mounts and the // markers useState initializer re-reads the server payload, avoiding // an in-render setState pattern to seed from the query. return ; } function TemplateEditorBody({ templateId, template, }: { templateId: string; template: TemplateData; }) { const qc = useQueryClient(); const [markers, setMarkers] = useState(template.overlayPositions ?? []); // Track the "last saved" baseline as state (not a ref) so the dirty // check re-runs on save. React Compiler forbids reading refs during // render, and the isDirty memo needs a stable baseline that updates // exactly when we commit a save. const [savedMarkers, setSavedMarkers] = useState(template.overlayPositions ?? []); const [pending, setPending] = useState(null); const [pendingToken, setPendingToken] = useState(''); const [saving, setSaving] = useState(false); const [savedMsg, setSavedMsg] = useState(null); const [pageNumber, setPageNumber] = useState(1); const [numPages, setNumPages] = useState(null); const [pageWidth, setPageWidth] = useState(680); const [dragState, setDragState] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const [previewLoading, setPreviewLoading] = useState(false); const [previewInterestId, setPreviewInterestId] = useState(''); const [autoDetectLoading, setAutoDetectLoading] = useState(false); const [autoDetectMsg, setAutoDetectMsg] = useState(null); const pageContainerRef = useRef(null); const outerColumnRef = useRef(null); const fileInputRef = useRef(null); const pdfUrl = template.sourceFileId ? `/api/v1/files/${template.sourceFileId}/preview` : null; // ─── Unsaved changes detection ──────────────────────────────────────────── const isDirty = useMemo( () => JSON.stringify(markers) !== JSON.stringify(savedMarkers), [markers, savedMarkers], ); useEffect(() => { if (!isDirty) return; const handler = (e: BeforeUnloadEvent) => { e.preventDefault(); // Modern browsers ignore the returnValue string and show their own // generic "you have unsaved changes" prompt - setting it still // triggers the prompt, just without our wording. e.returnValue = ''; }; window.addEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler); }, [isDirty]); // ─── Responsive PDF width ───────────────────────────────────────────────── useEffect(() => { const el = outerColumnRef.current; if (!el) return; const ro = new ResizeObserver((entries) => { const w = entries[0]?.contentRect.width ?? 680; // Subtract Card padding/border so the PDF doesn't overshoot the // container. 32 = px-4 (16) × 2. setPageWidth(Math.max(360, Math.floor(w - 32))); }); ro.observe(el); return () => ro.disconnect(); }, []); // ─── Click-to-place (only when not dragging) ────────────────────────────── function handlePageClick(e: React.MouseEvent) { if (dragState) return; // drag in progress; ignore click const container = pageContainerRef.current; if (!container) return; // Ignore clicks bubbling from a marker (handled by its own onClick). const target = e.target as HTMLElement; if (target.dataset.markerHandle === 'true') return; const rect = container.getBoundingClientRect(); const x = (e.clientX - rect.left) / rect.width; const y = (e.clientY - rect.top) / rect.height; if (x < 0 || x > 1 || y < 0 || y > 1) return; setPending({ x, y, page: pageNumber }); setPendingToken(TOKEN_OPTIONS[0] ?? ''); } function commitPending() { if (!pending || !pendingToken) return; const entry: FieldMapEntry = { token: pendingToken, page: pending.page, x: pending.x, y: pending.y, w: DEFAULT_MARKER_W, h: DEFAULT_MARKER_H, }; setMarkers((m) => [...m, entry]); setPending(null); setPendingToken(''); } function cancelPending() { setPending(null); setPendingToken(''); } function removeMarker(index: number) { setMarkers((m) => m.filter((_, i) => i !== index)); } // ─── Drag + resize ──────────────────────────────────────────────────────── const onMarkerMouseDown = useCallback( (index: number, kind: DragKind) => (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); const container = pageContainerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const m = markers[index]; if (!m) return; setDragState({ index, kind, startMarkerX: m.x, startMarkerY: m.y, startMarkerW: m.w ?? DEFAULT_MARKER_W, startMarkerH: m.h ?? DEFAULT_MARKER_H, startClientX: e.clientX, startClientY: e.clientY, containerW: rect.width, containerH: rect.height, }); }, [markers], ); useEffect(() => { if (!dragState) return; function onMove(e: MouseEvent) { if (!dragState) return; const dxPct = (e.clientX - dragState.startClientX) / dragState.containerW; const dyPct = (e.clientY - dragState.startClientY) / dragState.containerH; setMarkers((current) => { const next = current.slice(); const m = next[dragState.index]; if (!m) return current; const w = m.w ?? DEFAULT_MARKER_W; const h = m.h ?? DEFAULT_MARKER_H; if (dragState.kind === 'move') { // Clamp so the box stays fully on-page. next[dragState.index] = { ...m, x: clamp(dragState.startMarkerX + dxPct, 0, 1 - w), y: clamp(dragState.startMarkerY + dyPct, 0, 1 - h), }; } else { // Resize from a specific corner; the opposing corner stays // pinned, so x/y/w/h all change for NW (drag the top-left // corner: x and y increase by the delta, w and h decrease by it). let nx = dragState.startMarkerX; let ny = dragState.startMarkerY; let nw = dragState.startMarkerW; let nh = dragState.startMarkerH; if (dragState.kind === 'resize-nw') { nx = dragState.startMarkerX + dxPct; ny = dragState.startMarkerY + dyPct; nw = dragState.startMarkerW - dxPct; nh = dragState.startMarkerH - dyPct; } else if (dragState.kind === 'resize-ne') { ny = dragState.startMarkerY + dyPct; nw = dragState.startMarkerW + dxPct; nh = dragState.startMarkerH - dyPct; } else if (dragState.kind === 'resize-sw') { nx = dragState.startMarkerX + dxPct; nw = dragState.startMarkerW - dxPct; nh = dragState.startMarkerH + dyPct; } else if (dragState.kind === 'resize-se') { nw = dragState.startMarkerW + dxPct; nh = dragState.startMarkerH + dyPct; } // Enforce min size + clamp so the box stays on-page. nw = Math.max(MIN_MARKER_DIM, Math.min(1, nw)); nh = Math.max(MIN_MARKER_DIM, Math.min(1, nh)); nx = clamp(nx, 0, 1 - nw); ny = clamp(ny, 0, 1 - nh); next[dragState.index] = { ...m, x: nx, y: ny, w: nw, h: nh }; } return next; }); } function onUp() { setDragState(null); } window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, [dragState]); // ─── Save ──────────────────────────────────────────────────────────────── async function save() { setSaving(true); setSavedMsg(null); try { await apiFetch(`/api/v1/document-templates/${templateId}`, { method: 'PATCH', body: { overlayPositions: markers }, }); setSavedMarkers(markers); await qc.invalidateQueries({ queryKey: ['document-template', templateId] }); setSavedMsg('Markers saved.'); } catch (err) { toastError(err); } finally { setSaving(false); } } // ─── Preview ───────────────────────────────────────────────────────────── async function generatePreview() { if (!previewInterestId) { toastError(new Error('Pick an interest to preview against.')); return; } setPreviewLoading(true); try { const res = await apiFetch<{ data: { previewUrl: string } }>( `/api/v1/document-templates/${templateId}/preview`, { method: 'POST', body: { interestId: previewInterestId } }, ); setPreviewUrl(res.data.previewUrl); } catch (err) { toastError(err); } finally { setPreviewLoading(false); } } // ─── Auto-detect anchors (Phase 4) ─────────────────────────────────────── // Calls the field-detector service via POST /detect-fields, which scans // the current source PDF's text content for SIGNATURE / DATE / INITIALS / // NAME / EMAIL anchors. Converts the returned DetectedField[] (percent // 0..100) into FieldMapEntry markers (percent 0..1) and appends them to // the existing field map. Users retag tokens via the per-marker dropdown. async function autoDetect() { setAutoDetectLoading(true); setAutoDetectMsg(null); try { const res = await apiFetch<{ data: { fields: Array<{ type: string; pageNumber: number; pageX: number; pageY: number; pageWidth: number; pageHeight: number; confidence: number; anchorText: string; inferredRecipientLabel?: string | null; }>; totalAnchors: number; }; }>(`/api/v1/document-templates/${templateId}/detect-fields`, { method: 'POST' }); if (res.data.fields.length === 0) { setAutoDetectMsg('No anchors detected. Place markers manually.'); return; } // Map detected type → best-guess merge token. Falls back to first // sorted token when the detector finds a Documenso-only field // (SIGNATURE / INITIALS) that has no direct merge-token equivalent // in the in-app fill pipeline - the user retags from the dropdown. const fallbackToken = TOKEN_OPTIONS[0] ?? ''; const pick = (candidates: string[]): string => { for (const c of candidates) { if (TOKEN_OPTIONS.includes(c)) return c; } return fallbackToken; }; const tokenForType = (type: string): string => { switch (type.toUpperCase()) { case 'DATE': return pick(['{{eoi.dateGenerated}}', '{{eoi.todayDate}}', '{{date}}']); case 'NAME': return pick(['{{client.fullName}}', '{{client.firstName}}']); case 'EMAIL': return pick(['{{client.email}}']); default: return fallbackToken; } }; const newMarkers: FieldMap = res.data.fields.map((f) => ({ token: tokenForType(f.type), page: f.pageNumber, x: f.pageX / 100, y: f.pageY / 100, w: Math.max(MIN_MARKER_DIM, f.pageWidth / 100), h: Math.max(MIN_MARKER_DIM, f.pageHeight / 100), })); setMarkers((current) => [...current, ...newMarkers]); setAutoDetectMsg( `Added ${newMarkers.length} marker${newMarkers.length === 1 ? '' : 's'} from auto-detect. Review the assigned tokens (defaults are best-guess) and save.`, ); } catch (err) { toastError(err); } finally { setAutoDetectLoading(false); } } // ─── New-PDF upload (replace source) ───────────────────────────────────── async function onReplacePdf(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; try { const fd = new FormData(); fd.append('file', file); const res = await fetch(`/api/v1/document-templates/${templateId}/source-pdf`, { method: 'POST', body: fd, }); if (!res.ok) throw new Error((await res.text()) || 'Upload failed'); const json = (await res.json()) as { data?: { pageCount?: number } }; const newPageCount = json.data?.pageCount; if (newPageCount && numPages && newPageCount < numPages) { toastError( new Error( `New PDF has ${newPageCount} pages; the previous source had ${numPages}. Markers on pages ${newPageCount + 1}–${numPages} are now orphaned and won't render.`, ), ); } await qc.invalidateQueries({ queryKey: ['document-template', templateId] }); } catch (err) { toastError(err); } finally { if (fileInputRef.current) fileInputRef.current.value = ''; } } // ─── Derived state ──────────────────────────────────────────────────────── const visibleMarkers = useMemo( () => markers .map((m, i) => ({ marker: m, index: i })) .filter((row) => row.marker.page === pageNumber), [markers, pageNumber], ); const placedTokens = useMemo(() => new Set(markers.map((m) => m.token)), [markers]); const requiredTokens = template.mergeFields ?? []; const unplacedRequired = requiredTokens.filter((t) => !placedTokens.has(t)); if (!pdfUrl) { return (
This template has no source PDF attached. Upload one to get started.
); } return (
{isDirty ? ( Unsaved changes ) : null}
} /> {savedMsg ?

{savedMsg}

: null} {autoDetectMsg ?

{autoDetectMsg}

: null}
Page {pageNumber} {numPages ? ` / ${numPages}` : ''} {numPages && numPages > 1 ? (
) : null}
setNumPages(n)} loading={
Loading PDF…
} onLoadError={(err) => toastError(err)} >
{visibleMarkers.map(({ marker, index }) => ( onMarkerMouseDown(index, kind)} onContextMenu={(e) => { e.preventDefault(); removeMarker(index); }} /> ))} {pending && pending.page === pageNumber ? (
) : null}
{pending ? ( New marker
) : null} {unplacedRequired.length > 0 ? ( Required tokens unplaced ({unplacedRequired.length})
    {unplacedRequired.map((t) => (
  • {t}
  • ))}
) : null} Markers on page {pageNumber} ({visibleMarkers.length}) {visibleMarkers.length === 0 ? (

Click on the PDF to drop your first marker.

) : ( visibleMarkers.map(({ marker, index }) => (
{marker.token}
)) )}
Live preview setPreviewInterestId(e.target.value)} placeholder="Paste an interest UUID" className="w-full rounded-md border bg-background px-2 py-1 text-xs" /> {previewUrl ? ( Open preview PDF ) : null}
); } function clamp(v: number, lo: number, hi: number): number { return Math.min(hi, Math.max(lo, v)); } function MarkerOverlay({ marker, onMouseDown, onResize, onContextMenu, }: { marker: FieldMapEntry; onMouseDown: (e: React.MouseEvent) => void; onResize: (kind: DragKind) => (e: React.MouseEvent) => void; onContextMenu: (e: React.MouseEvent) => void; }) { const corners: Array<{ kind: DragKind; pos: string; cursor: string }> = [ { kind: 'resize-nw', pos: 'top-0 left-0 -translate-x-1/2 -translate-y-1/2', cursor: 'nwse-resize', }, { kind: 'resize-ne', pos: 'top-0 right-0 translate-x-1/2 -translate-y-1/2', cursor: 'nesw-resize', }, { kind: 'resize-sw', pos: 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2', cursor: 'nesw-resize', }, { kind: 'resize-se', pos: 'bottom-0 right-0 translate-x-1/2 translate-y-1/2', cursor: 'nwse-resize', }, ]; return (
{marker.token} {corners.map((c) => (
))}
); }