'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 (
);
}
return (
}
/>
{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) => (
))}
);
}