'use client'; import { useMemo, useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Document, Page, pdfjs } from 'react-pdf'; import { Loader2, Save, Trash2, 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; } interface PendingMarker { x: number; y: number; page: number; } const TOKEN_OPTIONS = Array.from(VALID_MERGE_TOKENS).sort(); const DEFAULT_MARKER_W = 0.18; const DEFAULT_MARKER_H = 0.04; /** * Phase 7.1 — page-1 PDF marker editor. Click anywhere on the rendered * PDF to drop a marker, pick which merge token it represents, save. * * Scope intentionally narrow: * - Page 1 only (multi-page page-picker is a 7.2 ticket). * - Add + delete markers; drag-to-move + corner-resize defer to 7.2. * - Coordinates stored as percent of page width/height so a future * page-size swap (A4 ↔ Letter) doesn't shift placements. */ 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. // Avoids the in-render setState pattern (React anti-pattern) that // a single component would otherwise need to seed from the query. return ; } function TemplateEditorBody({ templateId, template, }: { templateId: string; template: TemplateData; }) { const qc = useQueryClient(); const [markers, setMarkers] = useState(template.overlayPositions ?? []); const [pending, setPending] = useState(null); const [pendingToken, setPendingToken] = useState(''); const [saving, setSaving] = useState(false); const [savedMsg, setSavedMsg] = useState(null); const pageContainerRef = useRef(null); const pdfUrl = template.sourceFileId ? `/api/v1/files/${template.sourceFileId}/preview` : null; function handlePageClick(e: React.MouseEvent) { const container = pageContainerRef.current; if (!container) 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: 1 }); 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)); } async function save() { setSaving(true); setSavedMsg(null); try { await apiFetch(`/api/v1/document-templates/${templateId}`, { method: 'PATCH', body: { overlayPositions: markers }, }); await qc.invalidateQueries({ queryKey: ['document-template', templateId] }); setSavedMsg('Markers saved.'); } catch (err) { toastError(err); } finally { setSaving(false); } } const visibleMarkers = useMemo(() => markers.filter((m) => m.page === 1), [markers]); if (!pdfUrl) { return (
This template has no source PDF attached. Upload one from the template list before opening the editor.
); } return (
Page 1 {/* Wrapper carries the click handler. react-pdf renders the actual page inside; we overlay markers as positioned divs using the same percent coordinates the server-side fill path consumes. */}
Loading PDF…
} onLoadError={(err) => { // Surface load errors via toast rather than blowing up // — a bad source PDF shouldn't crash the editor shell. toastError(err); }} > {visibleMarkers.map((m, i) => (
{m.token}
))} {pending ? (
) : null}
{pending ? ( New marker
) : null} Markers ({visibleMarkers.length}) {visibleMarkers.length === 0 ? (

Click on the PDF to drop your first marker.

) : ( visibleMarkers.map((m, i) => (
{m.token}
)) )}
{savedMsg ?

{savedMsg}

: null}
); }