pdf-trainer-ui / src /App.jsx
Avinashnalla7's picture
UI: align table columns with anchors
de765ce
import { useEffect, useMemo, useRef, useState } from "react";
import { Document, Page, pdfjs } from "react-pdf";
import workerSrc from "pdfjs-dist/build/pdf.worker.min.mjs?url";
import "react-pdf/dist/Page/AnnotationLayer.css";
import "react-pdf/dist/Page/TextLayer.css";
pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;
/**
* FIELDS:
* - scalar fields use: anchor + value (stored as offset from anchor)
* - items uses: table + header + anchors + columns (+ row_height_hint)
*/
const FIELDS = [
{ fieldId: "facility_organization", label: "Facility / Organization", type: "entity" },
{ fieldId: "case_location", label: "Case Location / Address", type: "text" },
{ fieldId: "vendor", label: "Vendor", type: "entity" },
{ fieldId: "physician_name", label: "Physician Name", type: "person" },
{ fieldId: "date_of_surgery", label: "Date of Surgery", type: "date" },
{ fieldId: "items", label: "Items / Line Items", type: "table" },
];
// Table config keys (trainer only)
const TABLE_ANCHORS = [
{ key: "item_number", expected_text: "Item Number" },
{ key: "description", expected_text: "Description" },
{ key: "qty", expected_text: "Qty" },
];
const TABLE_COLUMNS = [
{ key: "item_number", label: "Item Number" },
{ key: "description", label: "Description" },
{ key: "qty", label: "Qty" },
];
/** ---------- normalization helpers ---------- */
function normBBox(b, w, h) {
return {
x: +(b.x / w).toFixed(6),
y: +(b.y / h).toFixed(6),
w: +(b.w / w).toFixed(6),
h: +(b.h / h).toFixed(6),
};
}
function normOffset(dx, dy, w, h, pageW, pageH) {
return {
dx: +(dx / pageW).toFixed(6),
dy: +(dy / pageH).toFixed(6),
w: +(w / pageW).toFixed(6),
h: +(h / pageH).toFixed(6),
};
}
function applyOffset(anchorBox, offsetPx) {
if (!anchorBox || !offsetPx) return null;
return {
x: anchorBox.x + offsetPx.dx,
y: anchorBox.y + offsetPx.dy,
w: offsetPx.w,
h: offsetPx.h,
};
}
function toRelativeBBox(childAbs, parentAbs) {
if (!childAbs || !parentAbs) return null;
return {
x: childAbs.x - parentAbs.x,
y: childAbs.y - parentAbs.y,
w: childAbs.w,
h: childAbs.h,
};
}
function clampBBoxToParent(childAbs, parentAbs) {
if (!childAbs || !parentAbs) return childAbs;
const x = Math.max(parentAbs.x, Math.min(childAbs.x, parentAbs.x + parentAbs.w));
const y = Math.max(parentAbs.y, Math.min(childAbs.y, parentAbs.y + parentAbs.h));
const x2 = Math.max(parentAbs.x, Math.min(childAbs.x + childAbs.w, parentAbs.x + parentAbs.w));
const y2 = Math.max(parentAbs.y, Math.min(childAbs.y + childAbs.h, parentAbs.y + parentAbs.h));
return { x, y, w: Math.max(0, x2 - x), h: Math.max(0, y2 - y) };
}
/** ---------- UI styles ---------- */
const UI = {
panelBg: "#141414",
panelText: "#EDEDED",
subtle: "#A7A7A7",
border: "rgba(255,255,255,0.10)",
btnBase: {
width: "100%",
padding: "10px 12px",
borderRadius: 10,
border: "1px solid rgba(255,255,255,0.15)",
fontWeight: 700,
cursor: "pointer",
userSelect: "none",
},
greenDark: { background: "#0f7a36", color: "#fff" },
blue: { background: "#2563eb", color: "#fff" },
green2: { background: "#22c55e", color: "#fff" },
purple: { background: "#7c3aed", color: "#fff" },
orange: { background: "#f59e0b", color: "#111" },
teal: { background: "#14b8a6", color: "#081016" },
gray: { background: "rgba(255,255,255,0.08)", color: "#fff" },
pill: (active, colorObj) => ({
padding: "8px 10px",
borderRadius: 10,
border: active ? "1px solid rgba(255,255,255,0.35)" : "1px solid rgba(255,255,255,0.15)",
...colorObj,
opacity: active ? 1 : 0.65,
cursor: "pointer",
flex: 1,
fontWeight: 800,
textAlign: "center",
}),
label: { fontSize: 12, color: "#CFCFCF", marginBottom: 6, fontWeight: 700 },
input: {
width: "100%",
padding: 10,
borderRadius: 10,
border: "1px solid rgba(255,255,255,0.12)",
background: "rgba(255,255,255,0.06)",
color: "#fff",
outline: "none",
},
select: {
width: "100%",
padding: 10,
borderRadius: 10,
border: "1px solid rgba(255,255,255,0.12)",
background: "rgba(255,255,255,0.06)",
color: "#fff",
outline: "none",
},
};
function isTableField(field) {
return field?.type === "table";
}
const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8000";
export default function App() {
// PDF comes from pdf_id in URL
const [pdfId, setPdfId] = useState(null);
const [pdfUrl, setPdfUrl] = useState(null);
const [pdfName, setPdfName] = useState(null);
// NEW: template id comes from URL OR manual input
const [templateId, setTemplateId] = useState("");
const [pageSize, setPageSize] = useState({ w: 0, h: 0 });
const [selectedFieldId, setSelectedFieldId] = useState(FIELDS[0].fieldId);
const [scalarMode, setScalarMode] = useState("anchor"); // anchor | value
const [tableMode, setTableMode] = useState("table"); // table | header | anchor | column
const [selectedTableAnchorKey, setSelectedTableAnchorKey] = useState(TABLE_ANCHORS[0].key);
const [selectedColumnKey, setSelectedColumnKey] = useState(TABLE_COLUMNS[0].key);
const [offsetMode, setOffsetMode] = useState(true);
const [rowHeightHintPx, setRowHeightHintPx] = useState(0);
const [scale, setScale] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [spaceDown, setSpaceDown] = useState(false);
const [isPanning, setIsPanning] = useState(false);
const panStartRef = useRef(null);
const stageRef = useRef(null);
const [draft, setDraft] = useState(null);
const dragStartRef = useRef(null);
const overlayRef = useRef(null);
const [showPreview, setShowPreview] = useState(true);
const [sendStatus, setSendStatus] = useState({ state: "idle", msg: "" }); // idle|sending|ok|err
const [boxes, setBoxes] = useState(() => {
const o = {};
for (const f of FIELDS) {
if (f.type === "table") {
const anchorsAbs = {};
for (const a of TABLE_ANCHORS) anchorsAbs[a.key] = null;
const columnsAbs = {};
for (const c of TABLE_COLUMNS) columnsAbs[c.key] = null;
o[f.fieldId] = {
kind: "table",
tableAbs: null,
headerAbs: null,
anchorsAbs,
columnsAbs,
};
} else {
o[f.fieldId] = {
kind: "scalar",
anchor: null,
valueAbs: null,
valueOffsetPx: null,
};
}
}
return o;
});
// Spacebar handling for pan
useEffect(() => {
function onKeyDown(e) {
if (e.code === "Space") {
e.preventDefault();
setSpaceDown(true);
}
}
function onKeyUp(e) {
if (e.code === "Space") {
e.preventDefault();
setSpaceDown(false);
setIsPanning(false);
panStartRef.current = null;
}
}
window.addEventListener("keydown", onKeyDown, { passive: false });
window.addEventListener("keyup", onKeyUp, { passive: false });
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
};
}, []);
// Read pdf_id + template_id from URL and load PDF from backend
useEffect(() => {
const sp = new URLSearchParams(window.location.search);
console.log("URL", window.location.href);
console.log("query", Object.fromEntries(sp.entries()));
const id = sp.get("pdf_id") || sp.get("id") || sp.get("pdfId") || sp.get("pdf");
setPdfId(id || null);
const tid = sp.get("template_id");
setTemplateId((tid || "").trim());
if (!id) {
setPdfUrl(null);
setPdfName(null);
return;
}
let alive = true;
let objectUrl = null;
async function loadPdf() {
try {
const resp = await fetch(`${API_BASE}/api/pdf/${encodeURIComponent(id)}`);
if (!resp.ok) throw new Error(`Failed to fetch PDF (${resp.status})`);
const name = resp.headers.get("X-PDF-Name") || `document_${id}.pdf`;
const blob = await resp.blob();
objectUrl = URL.createObjectURL(blob);
if (!alive) return;
setPdfUrl(objectUrl);
setPdfName(name);
setScale(1);
setPan({ x: 0, y: 0 });
} catch (e) {
console.error(e);
if (!alive) return;
setPdfUrl(null);
setPdfName(null);
}
}
loadPdf();
return () => {
alive = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, []);
const selectedField = useMemo(
() => FIELDS.find((f) => f.fieldId === selectedFieldId),
[selectedFieldId]
);
const selectedIsTable = isTableField(selectedField);
function getPoint(e) {
const el = overlayRef.current;
if (!el) return null;
const r = el.getBoundingClientRect();
const mx = e.clientX - r.left;
const my = e.clientY - r.top;
const x = mx / scale;
const y = my / scale;
return {
x: Math.max(0, Math.min(x, pageSize.w)),
y: Math.max(0, Math.min(y, pageSize.h)),
};
}
function onMouseDown(e) {
if (!pdfUrl || !pageSize.w || !pageSize.h) return;
if (spaceDown) {
setIsPanning(true);
panStartRef.current = {
mouseX: e.clientX,
mouseY: e.clientY,
panX: pan.x,
panY: pan.y,
};
return;
}
const p = getPoint(e);
if (!p) return;
dragStartRef.current = p;
setDraft({ x: p.x, y: p.y, w: 0, h: 0 });
}
function onMouseMove(e) {
if (isPanning && panStartRef.current) {
const s = panStartRef.current;
const dx = e.clientX - s.mouseX;
const dy = e.clientY - s.mouseY;
setPan({ x: s.panX + dx, y: s.panY + dy });
return;
}
if (!dragStartRef.current) return;
const p = getPoint(e);
if (!p) return;
const s = dragStartRef.current;
const x1 = Math.min(s.x, p.x);
const y1 = Math.min(s.y, p.y);
const x2 = Math.max(s.x, p.x);
const y2 = Math.max(s.y, p.y);
setDraft({ x: x1, y: y1, w: x2 - x1, h: y2 - y1 });
}
function onMouseUp() {
if (isPanning) {
setIsPanning(false);
panStartRef.current = null;
return;
}
if (!dragStartRef.current) return;
dragStartRef.current = null;
if (!draft || draft.w < 8 || draft.h < 8) {
setDraft(null);
return;
}
setBoxes((prev) => {
const next = structuredClone(prev);
const st = next[selectedFieldId];
if (!st) return prev;
if (selectedIsTable && st.kind === "table") {
if (tableMode === "table") {
st.tableAbs = draft;
if (st.headerAbs) st.headerAbs = clampBBoxToParent(st.headerAbs, st.tableAbs);
for (const k of Object.keys(st.anchorsAbs)) {
if (st.anchorsAbs[k]) st.anchorsAbs[k] = clampBBoxToParent(st.anchorsAbs[k], st.tableAbs);
}
for (const k of Object.keys(st.columnsAbs)) {
if (st.columnsAbs[k]) st.columnsAbs[k] = clampBBoxToParent(st.columnsAbs[k], st.tableAbs);
}
}
if (tableMode === "header") {
st.headerAbs = st.tableAbs ? clampBBoxToParent(draft, st.tableAbs) : draft;
}
if (tableMode === "anchor") {
const key = selectedTableAnchorKey;
st.anchorsAbs[key] = st.tableAbs ? clampBBoxToParent(draft, st.tableAbs) : draft;
}
if (tableMode === "column") {
const key = selectedColumnKey;
st.columnsAbs[key] = st.tableAbs ? clampBBoxToParent(draft, st.tableAbs) : draft;
}
next[selectedFieldId] = st;
return next;
}
if (!selectedIsTable && st.kind === "scalar") {
if (scalarMode === "anchor") {
st.anchor = draft;
} else {
if (offsetMode) {
if (!st.anchor) {
st.valueAbs = draft;
st.valueOffsetPx = null;
} else {
st.valueOffsetPx = {
dx: draft.x - st.anchor.x,
dy: draft.y - st.anchor.y,
w: draft.w,
h: draft.h,
};
st.valueAbs = null;
}
} else {
st.valueAbs = draft;
st.valueOffsetPx = null;
}
}
next[selectedFieldId] = st;
return next;
}
return prev;
});
setDraft(null);
}
function clearCurrent(kind) {
setBoxes((prev) => {
const next = structuredClone(prev);
const st = next[selectedFieldId];
if (!st) return prev;
if (selectedIsTable && st.kind === "table") {
if (kind === "table") st.tableAbs = null;
if (kind === "header") st.headerAbs = null;
if (kind === "anchor") st.anchorsAbs[selectedTableAnchorKey] = null;
if (kind === "column") st.columnsAbs[selectedColumnKey] = null;
return next;
}
if (!selectedIsTable && st.kind === "scalar") {
if (kind === "anchor") st.anchor = null;
if (kind === "value") {
st.valueAbs = null;
st.valueOffsetPx = null;
}
return next;
}
return prev;
});
}
function getScalarDisplayValueBox(fieldId) {
const st = boxes[fieldId];
if (!st || st.kind !== "scalar") return null;
if (!offsetMode) return st.valueAbs;
return applyOffset(st.anchor, st.valueOffsetPx);
}
function buildConfig() {
if (!pageSize.w || !pageSize.h) return null;
return {
form_id: pdfId ? `trainer_${pdfId}` : "trainer_form_v1",
version: 3,
page: 1,
scalar_value_region_mode: offsetMode ? "offset_from_anchor_v1" : "absolute_bbox_v1",
fields: FIELDS.map((f) => {
const st = boxes[f.fieldId];
if (f.type === "table" && st?.kind === "table") {
const tableAbs = st.tableAbs;
const headerAbs = st.headerAbs;
const table_bbox_norm = tableAbs ? normBBox(tableAbs, pageSize.w, pageSize.h) : null;
const header_bbox_norm = headerAbs ? normBBox(headerAbs, pageSize.w, pageSize.h) : null;
const row_height_hint_norm =
rowHeightHintPx > 0 ? +(rowHeightHintPx / pageSize.h).toFixed(6) : null;
const table_anchors = TABLE_ANCHORS.map((a) => {
const abs = st.anchorsAbs[a.key];
return {
key: a.key,
expected_text: a.expected_text,
bbox_norm: abs ? normBBox(abs, pageSize.w, pageSize.h) : null,
};
});
const columns =
tableAbs
? TABLE_COLUMNS.map((c) => {
const abs = st.columnsAbs[c.key];
const rel = abs ? toRelativeBBox(abs, tableAbs) : null;
return {
key: c.key,
label: c.label,
bbox_rel_norm: rel ? normBBox(rel, tableAbs.w, tableAbs.h) : null,
};
})
: TABLE_COLUMNS.map((c) => ({
key: c.key,
label: c.label,
bbox_rel_norm: null,
}));
return {
field_id: f.fieldId,
label: f.label,
type: f.type,
table_bbox_norm,
header_bbox_norm,
row_height_hint_norm,
columns,
table_anchors,
notes: "Anchors are used at runtime to localize table/header/columns under drift.",
};
}
if (st?.kind === "scalar") {
const anchorNorm = st.anchor ? normBBox(st.anchor, pageSize.w, pageSize.h) : null;
const valueAbsNorm = st.valueAbs ? normBBox(st.valueAbs, pageSize.w, pageSize.h) : null;
const valueOffsetNorm =
st.valueOffsetPx && offsetMode
? normOffset(
st.valueOffsetPx.dx,
st.valueOffsetPx.dy,
st.valueOffsetPx.w,
st.valueOffsetPx.h,
pageSize.w,
pageSize.h
)
: null;
return {
field_id: f.fieldId,
label: f.label,
type: f.type,
anchor_bbox_norm: anchorNorm,
value_bbox_norm: offsetMode ? null : valueAbsNorm,
value_offset_norm: offsetMode ? valueOffsetNorm : null,
};
}
return { field_id: f.fieldId, label: f.label, type: f.type };
}),
notes:
"Trainer exports config only. Runtime should localize anchors then apply offsets/table mappings to extract values + line items.",
};
}
const previewConfig = useMemo(
() => buildConfig(),
[pageSize.w, pageSize.h, boxes, offsetMode, rowHeightHintPx, pdfId]
);
async function saveConfig() {
if (!pdfId) {
setSendStatus({ state: "err", msg: "Missing pdf_id in URL." });
return;
}
const tid = (templateId || "").trim();
if (!tid) {
setSendStatus({ state: "err", msg: "Missing template_id. Add ?template_id=... or type it in the left panel." });
return;
}
const cfg = buildConfig();
if (!cfg) {
setSendStatus({ state: "err", msg: "Config not ready. PDF not loaded or page size missing." });
return;
}
setSendStatus({ state: "sending", msg: "Saving..." });
try {
const resp = await fetch(`${API_BASE}/api/send-config`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pdf_id: pdfId, template_id: tid, config: cfg }),
});
if (!resp.ok) {
const t = await resp.text();
throw new Error(`Save failed (${resp.status}): ${t}`);
}
const data = await resp.json().catch(() => ({}));
setSendStatus({ state: "ok", msg: data?.message || "Saved." });
} catch (e) {
console.error(e);
setSendStatus({ state: "err", msg: e.message || "Save failed." });
}
}
const drawModeTitle = useMemo(() => {
if (!selectedField) return "";
if (selectedIsTable) return "Draw Mode (Table)";
return "Draw Mode (Scalar)";
}, [selectedField, selectedIsTable]);
const activeModeLabel = useMemo(() => {
if (!selectedField) return "";
if (!selectedIsTable) return scalarMode === "anchor" ? "Anchor (Blue)" : "Value (Green)";
if (tableMode === "table") return "Table Region (Purple)";
if (tableMode === "header") return "Header Row (Orange)";
if (tableMode === "anchor") return `Table Anchor (Blue): ${selectedTableAnchorKey}`;
if (tableMode === "column") return `Column (Teal): ${selectedColumnKey}`;
return "";
}, [selectedField, selectedIsTable, scalarMode, tableMode, selectedTableAnchorKey, selectedColumnKey]);
return (
<div style={{ display: "grid", gridTemplateColumns: "380px 1fr 420px", height: "100vh" }}>
{/* Left panel */}
<div
style={{
padding: 16,
borderRight: `1px solid ${UI.border}`,
overflow: "auto",
background: UI.panelBg,
color: UI.panelText,
}}
>
<h2 style={{ margin: "0 0 14px", fontSize: 26, letterSpacing: 0.2 }}>PDF Trainer</h2>
<div style={{ marginBottom: 14 }}>
<div style={UI.label}>Document Source</div>
<div style={{ fontSize: 12, color: UI.subtle, lineHeight: 1.4 }}>
<b>pdf_id:</b> {pdfId || "—"}
<br />
<b>file:</b> {pdfName || "—"}
</div>
{!pdfId && (
<div style={{ marginTop: 10, fontSize: 12, color: "#ffb4b4" }}>
Missing <b>pdf_id</b>. Open this page using the pipeline link from the email.
</div>
)}
</div>
{/* NEW: Template ID */}
<div style={{ marginBottom: 14 }}>
<div style={UI.label}>Template ID (required)</div>
<input
style={{ ...UI.input, fontWeight: 800 }}
value={templateId}
onChange={(e) => setTemplateId(e.target.value)}
placeholder="e.g. T1_IFACTOR_DELIVERED_ORDER"
/>
<div style={{ fontSize: 12, color: UI.subtle, marginTop: 6 }}>
Prefer URL: <code>?pdf_id=...&template_id=...</code>
</div>
</div>
{/* Zoom / Pan controls */}
<div style={{ marginBottom: 14 }}>
<div style={UI.label}>View (Zoom / Pan)</div>
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
<button
type="button"
onClick={() => setScale((s) => Math.max(0.5, +(s - 0.1).toFixed(2)))}
style={{ ...UI.btnBase, ...UI.gray, width: 56, padding: "10px 0" }}
title="Zoom out"
>
</button>
<input
type="range"
min="0.5"
max="2.5"
step="0.05"
value={scale}
onChange={(e) => setScale(+e.target.value)}
style={{ width: "100%" }}
/>
<button
type="button"
onClick={() => setScale((s) => Math.min(2.5, +(s + 0.1).toFixed(2)))}
style={{ ...UI.btnBase, ...UI.gray, width: 56, padding: "10px 0" }}
title="Zoom in"
>
+
</button>
</div>
<div style={{ display: "flex", gap: 10, marginTop: 10 }}>
<button
type="button"
onClick={() => {
setScale(1);
setPan({ x: 0, y: 0 });
}}
style={{ ...UI.btnBase, background: "#0f172a", color: "#fff" }}
>
Reset View (1x)
</button>
</div>
<div style={{ fontSize: 12, color: UI.subtle, marginTop: 8 }}>
Pan: hold <b>Space</b> and drag on the PDF.
</div>
</div>
{!selectedIsTable && (
<div style={{ marginBottom: 14 }}>
<label style={{ display: "flex", gap: 10, alignItems: "center", fontSize: 12 }}>
<input type="checkbox" checked={offsetMode} onChange={(e) => setOffsetMode(e.target.checked)} />
<span style={{ fontWeight: 800 }}>Value follows anchor (offset mode)</span>
</label>
<div style={{ fontSize: 12, color: UI.subtle, marginTop: 6 }}>
Offset mode = drift fix: store value region relative to anchor.
</div>
</div>
)}
{/* Field selector */}
<div style={{ marginBottom: 14 }}>
<div style={UI.label}>Field</div>
<select
value={selectedFieldId}
onChange={(e) => setSelectedFieldId(e.target.value)}
style={{ ...UI.select, padding: 14, fontSize: 16, fontWeight: 800 }}
>
{FIELDS.map((f) => (
<option key={f.fieldId} value={f.fieldId}>
{f.label}
</option>
))}
</select>
<div style={{ fontSize: 12, color: UI.subtle, marginTop: 8 }}>
Selected: <b style={{ color: "#fff" }}>{selectedField?.label}</b>{" "}
<span style={{ color: UI.subtle }}>({selectedField?.type})</span>
</div>
</div>
{selectedIsTable && (
<div style={{ marginBottom: 14 }}>
<div style={UI.label}>Row Height Hint (optional)</div>
<input
style={UI.input}
type="number"
min="0"
step="1"
value={rowHeightHintPx}
onChange={(e) => setRowHeightHintPx(Number(e.target.value || 0))}
placeholder="e.g. 28"
/>
<div style={{ fontSize: 12, color: UI.subtle, marginTop: 6 }}>
Used later to split rows. Leave 0 if unknown.
</div>
</div>
)}
{/* Draw mode */}
<div style={{ marginBottom: 14 }}>
<div style={UI.label}>{drawModeTitle}</div>
{!selectedIsTable && (
<div style={{ display: "flex", gap: 10 }}>
<div role="button" onClick={() => setScalarMode("anchor")} style={UI.pill(scalarMode === "anchor", UI.blue)}>
Anchor (Blue)
</div>
<div role="button" onClick={() => setScalarMode("value")} style={UI.pill(scalarMode === "value", UI.green2)}>
Value (Green)
</div>
</div>
)}
{selectedIsTable && (
<>
<div style={{ display: "flex", gap: 10, marginBottom: 10 }}>
<div role="button" onClick={() => setTableMode("table")} style={UI.pill(tableMode === "table", UI.purple)}>
Table (Purple)
</div>
<div role="button" onClick={() => setTableMode("header")} style={UI.pill(tableMode === "header", UI.orange)}>
Header (Orange)
</div>
</div>
<div style={{ display: "flex", gap: 10, marginBottom: 10 }}>
<div role="button" onClick={() => setTableMode("anchor")} style={UI.pill(tableMode === "anchor", UI.blue)}>
Anchor (Blue)
</div>
<div role="button" onClick={() => setTableMode("column")} style={UI.pill(tableMode === "column", UI.teal)}>
Column (Teal)
</div>
</div>
{tableMode === "anchor" && (
<div style={{ marginTop: 6 }}>
<div style={UI.label}>Which Anchor?</div>
<select value={selectedTableAnchorKey} onChange={(e) => setSelectedTableAnchorKey(e.target.value)} style={UI.select}>
{TABLE_ANCHORS.map((a) => (
<option key={a.key} value={a.key}>
{a.expected_text}
</option>
))}
</select>
<div style={{ fontSize: 12, color: UI.subtle, marginTop: 6 }}>
Draw a small box tightly around the printed header word(s).
</div>
</div>
)}
{tableMode === "column" && (
<div style={{ marginTop: 6 }}>
<div style={UI.label}>Which Column?</div>
<select value={selectedColumnKey} onChange={(e) => setSelectedColumnKey(e.target.value)} style={UI.select}>
{TABLE_COLUMNS.map((c) => (
<option key={c.key} value={c.key}>
{c.label}
</option>
))}
</select>
<div style={{ fontSize: 12, color: UI.subtle, marginTop: 6 }}>
Columns should be drawn inside the table region.
</div>
</div>
)}
</>
)}
<div style={{ fontSize: 12, color: UI.subtle, marginTop: 10 }}>
Active: <b style={{ color: "#fff" }}>{activeModeLabel}</b>
</div>
<div style={{ fontSize: 12, color: UI.subtle, marginTop: 6 }}>
Drag on the PDF to place this region. (Hold Space to pan)
</div>
</div>
{/* Clear buttons */}
<div style={{ display: "flex", gap: 10, marginBottom: 14 }}>
{!selectedIsTable ? (
<>
<button onClick={() => clearCurrent("anchor")} style={{ ...UI.btnBase, ...UI.gray }}>
Clear Anchor
</button>
<button onClick={() => clearCurrent("value")} style={{ ...UI.btnBase, ...UI.gray }}>
Clear Value
</button>
</>
) : (
<>
<button onClick={() => clearCurrent(tableMode)} style={{ ...UI.btnBase, ...UI.gray }}>
Clear Current ({tableMode})
</button>
<button onClick={() => clearCurrent("table")} style={{ ...UI.btnBase, ...UI.gray }}>
Clear Table
</button>
</>
)}
</div>
{/* SAVE */}
<button
onClick={saveConfig}
disabled={!pdfUrl || sendStatus.state === "sending"}
style={{
...UI.btnBase,
...(pdfUrl ? UI.greenDark : { background: "rgba(255,255,255,0.10)", color: "#aaa" }),
border: pdfUrl ? "1px solid rgba(22,163,74,0.55)" : "1px solid rgba(255,255,255,0.12)",
}}
>
{sendStatus.state === "sending" ? "Saving..." : "Save Config"}
</button>
{sendStatus.state !== "idle" && (
<div
style={{
marginTop: 10,
fontSize: 12,
color: sendStatus.state === "ok" ? "#86efac" : sendStatus.state === "err" ? "#fecaca" : UI.subtle,
}}
>
{sendStatus.msg}
</div>
)}
<hr style={{ margin: "16px 0", borderColor: UI.border }} />
<div style={{ display: "flex", gap: 10, marginBottom: 12 }}>
<button
type="button"
onClick={() => setShowPreview((v) => !v)}
style={{ ...UI.btnBase, background: "#0f172a", color: "#fff" }}
>
{showPreview ? "Hide Config Preview" : "Show Config Preview"}
</button>
</div>
{showPreview && (
<div style={{ marginTop: 14 }}>
<div style={{ fontWeight: 900, marginBottom: 8 }}>Config Preview</div>
<pre
style={{
margin: 0,
padding: 12,
borderRadius: 12,
background: "#0b0f14",
border: "1px solid rgba(255,255,255,0.10)",
color: "#d1d5db",
fontSize: 11,
lineHeight: 1.35,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
maxHeight: 260,
overflow: "auto",
}}
>
{previewConfig ? JSON.stringify(previewConfig, null, 2) : "PDF not loaded yet..."}
</pre>
</div>
)}
</div>
{/* Middle panel: PDF + overlay */}
<div style={{ padding: 16, overflow: "auto", background: "#111" }}>
{!pdfUrl ? (
<div style={{ color: "#999", lineHeight: 1.4 }}>
No PDF loaded.
<br />
Open using pipeline link:
<br />
<code style={{ color: "#ddd" }}>...?pdf_id=xxxx&template_id=T1_...</code>
</div>
) : (
<div
style={{
position: "relative",
width: "fit-content",
background: "#0b0b0b",
border: `1px solid ${UI.border}`,
borderRadius: 14,
padding: 12,
}}
>
<div
ref={stageRef}
style={{
position: "relative",
width: pageSize.w ? pageSize.w : "fit-content",
transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`,
transformOrigin: "top left",
}}
>
<Document
file={pdfUrl}
loading="Loading PDF..."
onLoadError={(err) => {
console.error("PDF load error:", err);
alert(err.message);
}}
>
<Page
pageNumber={1}
renderTextLayer={false}
renderAnnotationLayer={false}
onRenderSuccess={(page) => setPageSize({ w: page.width, h: page.height })}
/>
</Document>
{pageSize.w > 0 && pageSize.h > 0 && (
<div
ref={overlayRef}
style={{
position: "absolute",
left: 0,
top: 0,
width: pageSize.w,
height: pageSize.h,
cursor: spaceDown ? (isPanning ? "grabbing" : "grab") : "crosshair",
}}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={() => {
onMouseUp();
setIsPanning(false);
panStartRef.current = null;
}}
>
<svg width={pageSize.w} height={pageSize.h}>
{FIELDS.map((f) => {
const st = boxes[f.fieldId];
if (st?.kind === "scalar") {
const vDisp = getScalarDisplayValueBox(f.fieldId);
return (
<g key={f.fieldId}>
{st.anchor && (
<rect x={st.anchor.x} y={st.anchor.y} width={st.anchor.w} height={st.anchor.h} fill="none" stroke="#2563eb" strokeWidth="3" />
)}
{vDisp && (
<rect x={vDisp.x} y={vDisp.y} width={vDisp.w} height={vDisp.h} fill="none" stroke="#22c55e" strokeWidth="3" />
)}
</g>
);
}
if (st?.kind === "table") {
return (
<g key={f.fieldId}>
{st.tableAbs && (
<rect x={st.tableAbs.x} y={st.tableAbs.y} width={st.tableAbs.w} height={st.tableAbs.h} fill="none" stroke="#7c3aed" strokeWidth="3" />
)}
{st.headerAbs && (
<rect x={st.headerAbs.x} y={st.headerAbs.y} width={st.headerAbs.w} height={st.headerAbs.h} fill="none" stroke="#f59e0b" strokeWidth="3" />
)}
{TABLE_ANCHORS.map((a) => {
const b = st.anchorsAbs[a.key];
return (
b && <rect key={`a-${a.key}`} x={b.x} y={b.y} width={b.w} height={b.h} fill="none" stroke="#2563eb" strokeWidth="3" />
);
})}
{TABLE_COLUMNS.map((c) => {
const b = st.columnsAbs[c.key];
return (
b && <rect key={`c-${c.key}`} x={b.x} y={b.y} width={b.w} height={b.h} fill="none" stroke="#14b8a6" strokeWidth="3" />
);
})}
</g>
);
}
return null;
})}
{draft && !isPanning && (
<rect
x={draft.x}
y={draft.y}
width={draft.w}
height={draft.h}
fill="none"
stroke={
selectedIsTable
? tableMode === "table"
? "#7c3aed"
: tableMode === "header"
? "#f59e0b"
: tableMode === "anchor"
? "#2563eb"
: "#14b8a6"
: scalarMode === "anchor"
? "#2563eb"
: "#22c55e"
}
strokeWidth="3"
strokeDasharray="6 4"
/>
)}
</svg>
</div>
)}
</div>
</div>
)}
</div>
{/* Right panel */}
<div
style={{
padding: 16,
overflow: "auto",
background: "#0b0f14",
borderLeft: `1px solid ${UI.border}`,
color: "#e5e7eb",
}}
>
<h3 style={{ margin: "0 0 10px", fontSize: 18 }}>Config Preview</h3>
<div style={{ fontSize: 12, color: "#9aa4b2", marginBottom: 10 }}>
Live preview of what will be saved.
</div>
<pre
style={{
margin: 0,
padding: 12,
borderRadius: 12,
background: "#05070a",
border: "1px solid #1f2937",
color: "#d1d5db",
fontSize: 12,
lineHeight: 1.35,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{previewConfig ? JSON.stringify(previewConfig, null, 2) : "PDF not loaded yet..."}
</pre>
</div>
</div>
);
}