Files
bmp-project/packages/templatebuilder/frontend.ts
syntaxbullet 99569d2cf3 initial commit
2026-05-13 17:26:13 +02:00

611 lines
20 KiB
TypeScript

import * as pdfjsLib from "pdfjs-dist";
import type { PDFDocumentProxy, PageViewport } from "pdfjs-dist";
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdf.worker.mjs";
// ─── Types ────────────────────────────────────────────────────────────────────
type AntwortFormat = "einzelfrage" | "mehrfachfrage_ein_antwortfeld";
interface FrageVorlage {
id: string;
text: string;
}
interface FieldEntry {
id: string;
fieldName: string; // PDF AcroForm field name, or user-defined for manual
jsonKey: string; // Target nested JSON path
rect: [number, number, number, number]; // PDF user-space [x1, y1, x2, y2]
page: number; // 1-indexed
type: "acroform" | "manual";
label?: string;
antwortFormat?: AntwortFormat;
fragen?: FrageVorlage[];
}
// ─── State ────────────────────────────────────────────────────────────────────
let pdfDoc: PDFDocumentProxy | null = null;
let currentPage = 1;
let viewport: PageViewport | null = null;
let fields: FieldEntry[] = [];
let selectedFieldId: string | null = null;
let isDrawMode = false;
let drawStart: { x: number; y: number } | null = null;
let pendingManualRect: [number, number, number, number] | null = null;
let currentFilename = "";
// ─── DOM refs ─────────────────────────────────────────────────────────────────
const fileInput = q<HTMLInputElement>("#file-input");
const uploadBtn = q<HTMLButtonElement>("#upload-btn");
const drawBtn = q<HTMLButtonElement>("#draw-btn");
const saveBtn = q<HTMLButtonElement>("#save-btn");
const prevPageBtn = q<HTMLButtonElement>("#prev-page");
const nextPageBtn = q<HTMLButtonElement>("#next-page");
const pageInfo = q<HTMLSpanElement>("#page-info");
const fieldList = q<HTMLDivElement>("#field-list");
const fieldCount = q<HTMLSpanElement>("#field-count");
const filenameDisp = q<HTMLSpanElement>("#filename-display");
const uploadPrompt = q<HTMLDivElement>("#upload-prompt");
const canvases = q<HTMLDivElement>("#canvases");
const pdfCanvas = q<HTMLCanvasElement>("#pdf-canvas");
const overlayCanvas = q<HTMLCanvasElement>("#overlay-canvas");
// Key panel
const keyPanel = q<HTMLDivElement>("#key-panel");
const kpFieldName = q<HTMLSpanElement>("#kp-field-name");
const kpTypeBadge = q<HTMLSpanElement>("#kp-type-badge");
const kpNameRow = q<HTMLDivElement>("#kp-name-row");
const kpFieldId = q<HTMLInputElement>("#kp-field-id");
const kpJsonKey = q<HTMLInputElement>("#kp-json-key");
const kpLabel = q<HTMLInputElement>("#kp-label");
const kpAntwortFormat = q<HTMLSelectElement>("#kp-antwort-format");
const kpFragen = q<HTMLTextAreaElement>("#kp-fragen");
const kpAssign = q<HTMLButtonElement>("#kp-assign");
const kpCancel = q<HTMLButtonElement>("#kp-cancel");
const kpRemove = q<HTMLButtonElement>("#kp-remove");
function q<T extends Element>(sel: string): T {
return document.querySelector(sel) as T;
}
function csrfHeaders(extra: Record<string, string> = {}): Record<string, string> {
const csrf = document.cookie
.split("; ")
.find((part) => part.startsWith("bmp_demo_csrf="))
?.slice("bmp_demo_csrf=".length);
return csrf ? { ...extra, "X-BMP-CSRF": decodeURIComponent(csrf) } : extra;
}
// ─── PDF Loading ──────────────────────────────────────────────────────────────
uploadBtn.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", async () => {
const file = fileInput.files?.[0];
if (file) await loadPDF(file);
});
async function loadPDF(file: File) {
currentFilename = file.name;
filenameDisp.textContent = file.name;
selectedFieldId = null;
hideKeyPanel();
const buf = await file.arrayBuffer();
pdfDoc = await pdfjsLib.getDocument({ data: buf }).promise;
const acroFields = await extractAcroFields(pdfDoc);
fields = mergeWithSaved(file.name, acroFields);
currentPage = 1;
uploadPrompt.style.display = "none";
canvases.style.display = "inline-block";
drawBtn.disabled = false;
saveBtn.disabled = false;
await renderPage(1);
renderFieldList();
}
async function extractAcroFields(doc: PDFDocumentProxy): Promise<FieldEntry[]> {
const result: FieldEntry[] = [];
const seen = new Set<string>();
for (let p = 1; p <= doc.numPages; p++) {
const page = await doc.getPage(p);
const annotations = await page.getAnnotations();
for (const ann of annotations) {
if (ann.subtype === "Widget" && ann.fieldName) {
const key = `${ann.fieldName}::p${p}`;
if (!seen.has(key)) {
seen.add(key);
result.push({
id: `acro-p${p}-${ann.id ?? ann.fieldName}`,
fieldName: ann.fieldName,
jsonKey: "",
rect: ann.rect as [number, number, number, number],
page: p,
type: "acroform",
});
}
}
}
}
return result;
}
function mergeWithSaved(filename: string, acroFields: FieldEntry[]): FieldEntry[] {
const raw = localStorage.getItem(`tb:${filename}`);
if (!raw) return acroFields;
const saved: FieldEntry[] = JSON.parse(raw);
const savedByKey = new Map(saved.map(f => [`${f.fieldName}::p${f.page}`, f]));
const merged = acroFields.map(f => {
const s = savedByKey.get(`${f.fieldName}::p${f.page}`);
return s
? {
...f,
jsonKey: s.jsonKey,
label: s.label,
antwortFormat: s.antwortFormat,
fragen: s.fragen,
}
: f;
});
const manuals = saved.filter(f => f.type === "manual");
return [...merged, ...manuals];
}
// ─── Page Rendering ───────────────────────────────────────────────────────────
async function renderPage(pageNum: number) {
if (!pdfDoc) return;
const page = await pdfDoc.getPage(pageNum);
viewport = page.getViewport({ scale: 1.5 });
pdfCanvas.width = viewport.width;
pdfCanvas.height = viewport.height;
overlayCanvas.width = viewport.width;
overlayCanvas.height = viewport.height;
const ctx = pdfCanvas.getContext("2d")!;
await page.render({ canvasContext: ctx, viewport, canvas: pdfCanvas }).promise;
renderOverlay();
updatePagination();
}
function updatePagination() {
const total = pdfDoc?.numPages ?? 0;
pageInfo.textContent = total ? `Page ${currentPage} of ${total}` : "—";
prevPageBtn.disabled = currentPage <= 1;
nextPageBtn.disabled = !total || currentPage >= total;
}
prevPageBtn.addEventListener("click", async () => {
if (currentPage > 1) {
currentPage--;
selectedFieldId = null;
hideKeyPanel();
await renderPage(currentPage);
}
});
nextPageBtn.addEventListener("click", async () => {
if (pdfDoc && currentPage < pdfDoc.numPages) {
currentPage++;
selectedFieldId = null;
hideKeyPanel();
await renderPage(currentPage);
}
});
// ─── Overlay ─────────────────────────────────────────────────────────────────
function renderOverlay(drawPreview?: { x: number; y: number; w: number; h: number }) {
if (!viewport) return;
const ctx = overlayCanvas.getContext("2d")!;
ctx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
for (const field of fields.filter(f => f.page === currentPage)) {
const [l, t, w, h] = rectToCanvas(field.rect, viewport!);
const sel = field.id === selectedFieldId;
const mapped = field.jsonKey.trim() !== "";
if (sel) {
ctx.fillStyle = "rgba(37,99,235,0.12)";
ctx.fillRect(l, t, w, h);
ctx.strokeStyle = "#2563eb";
ctx.lineWidth = 2;
} else {
ctx.strokeStyle = mapped ? "#16a34a" : "#ea580c";
ctx.lineWidth = 1.5;
}
ctx.strokeRect(l, t, w, h);
// Label
const label = field.jsonKey || field.fieldName;
ctx.font = "10px monospace";
ctx.fillStyle = sel ? "#2563eb" : mapped ? "#16a34a" : "#ea580c";
const labelY = t > 14 ? t - 3 : t + h + 11;
ctx.fillText(label, l + 2, labelY);
}
if (drawPreview) {
ctx.strokeStyle = "#2563eb";
ctx.lineWidth = 2;
ctx.setLineDash([5, 4]);
ctx.strokeRect(drawPreview.x, drawPreview.y, drawPreview.w, drawPreview.h);
ctx.setLineDash([]);
}
}
function rectToCanvas(
rect: [number, number, number, number],
vp: PageViewport,
): [number, number, number, number] {
const [cx1, cy1, cx2, cy2] = vp.convertToViewportRectangle(rect);
const l = Math.min(cx1, cx2);
const t = Math.min(cy1, cy2);
return [l, t, Math.abs(cx2 - cx1), Math.abs(cy2 - cy1)];
}
function canvasToRect(
x1: number, y1: number, x2: number, y2: number,
vp: PageViewport,
): [number, number, number, number] {
const [a, b, c, d, e, f] = vp.transform as [number, number, number, number, number, number];
const det = a * d - b * c;
function inv(vx: number, vy: number): [number, number] {
return [
(d * vx - c * vy + (c * f - d * e)) / det,
(-b * vx + a * vy + (b * e - a * f)) / det,
];
}
const [px1, py1] = inv(x1, y1);
const [px2, py2] = inv(x2, y2);
return [
Math.min(px1, px2), Math.min(py1, py2),
Math.max(px1, px2), Math.max(py1, py2),
];
}
// ─── Mouse on overlay ────────────────────────────────────────────────────────
function canvasPos(e: MouseEvent) {
const r = overlayCanvas.getBoundingClientRect();
return {
x: (e.clientX - r.left) * (overlayCanvas.width / r.width),
y: (e.clientY - r.top) * (overlayCanvas.height / r.height),
};
}
overlayCanvas.addEventListener("click", (e) => {
if (isDrawMode || !viewport) return;
const { x, y } = canvasPos(e);
for (const field of fields.filter(f => f.page === currentPage)) {
const [l, t, w, h] = rectToCanvas(field.rect, viewport!);
if (x >= l && x <= l + w && y >= t && y <= t + h) {
selectField(field.id);
return;
}
}
selectedFieldId = null;
renderOverlay();
hideKeyPanel();
});
overlayCanvas.addEventListener("mousedown", (e) => {
if (!isDrawMode) return;
drawStart = canvasPos(e);
});
overlayCanvas.addEventListener("mousemove", (e) => {
if (!isDrawMode || !drawStart) return;
const { x, y } = canvasPos(e);
renderOverlay({
x: Math.min(drawStart.x, x),
y: Math.min(drawStart.y, y),
w: Math.abs(x - drawStart.x),
h: Math.abs(y - drawStart.y),
});
});
overlayCanvas.addEventListener("mouseup", (e) => {
if (!isDrawMode || !drawStart || !viewport) return;
const { x, y } = canvasPos(e);
const dx = Math.abs(x - drawStart.x);
const dy = Math.abs(y - drawStart.y);
if (dx > 8 && dy > 8) {
pendingManualRect = canvasToRect(
Math.min(drawStart.x, x), Math.min(drawStart.y, y),
Math.max(drawStart.x, x), Math.max(drawStart.y, y),
viewport,
);
exitDrawMode();
showNewManualPanel();
} else {
drawStart = null;
renderOverlay();
}
drawStart = null;
});
// ─── Draw mode ────────────────────────────────────────────────────────────────
drawBtn.addEventListener("click", () => {
if (isDrawMode) exitDrawMode();
else enterDrawMode();
});
function enterDrawMode() {
isDrawMode = true;
drawBtn.textContent = "Cancel Draw";
drawBtn.classList.add("active");
overlayCanvas.style.cursor = "crosshair";
hideKeyPanel();
}
function exitDrawMode() {
isDrawMode = false;
drawBtn.textContent = "Draw Field";
drawBtn.classList.remove("active");
overlayCanvas.style.cursor = "default";
drawStart = null;
renderOverlay();
}
// ─── Field selection ─────────────────────────────────────────────────────────
function selectField(id: string) {
selectedFieldId = id;
renderOverlay();
const field = fields.find(f => f.id === id)!;
kpFieldName.textContent = field.fieldName;
kpTypeBadge.textContent = field.type === "acroform" ? "AcroForm" : "Manual";
kpTypeBadge.className = `kp-badge kp-badge-${field.type}`;
kpJsonKey.value = field.jsonKey;
kpLabel.value = field.label ?? "";
kpAntwortFormat.value = field.antwortFormat ?? "einzelfrage";
kpFragen.value = (field.fragen ?? []).map((frage) => frage.text).join("\n");
kpNameRow.classList.add("hidden");
kpRemove.style.display = field.type === "manual" ? "" : "none";
keyPanel.classList.remove("hidden");
kpJsonKey.focus();
// Sync sidebar highlight
document.querySelectorAll(".field-item").forEach(el =>
el.classList.toggle("selected", el.getAttribute("data-id") === id));
document.querySelector(`.field-item[data-id="${id}"]`)
?.scrollIntoView({ block: "nearest" });
}
function showNewManualPanel() {
kpFieldName.textContent = "(new manual field)";
kpTypeBadge.textContent = "Manual";
kpTypeBadge.className = "kp-badge kp-badge-manual";
kpJsonKey.value = "";
kpLabel.value = "";
kpAntwortFormat.value = "einzelfrage";
kpFragen.value = "";
kpNameRow.classList.remove("hidden");
kpFieldId.value = "";
kpRemove.style.display = "none";
keyPanel.classList.remove("hidden");
kpFieldId.focus();
}
function hideKeyPanel() {
keyPanel.classList.add("hidden");
pendingManualRect = null;
}
// ─── Key panel actions ────────────────────────────────────────────────────────
function normalizeJsonKey(jsonKey: string): string {
return jsonKey.replace(/\.antwort$/, "").trim();
}
function parseFragen(lines: string, jsonKey: string): FrageVorlage[] {
const clean = lines
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const base = jsonKey.split(".").filter(Boolean).at(-1) || "frage";
return clean.map((text, index) => ({
id: `${base}_${index + 1}`,
text,
}));
}
kpAssign.addEventListener("click", () => {
const jsonKey = normalizeJsonKey(kpJsonKey.value);
const label = kpLabel.value.trim();
const antwortFormat = kpAntwortFormat.value as AntwortFormat;
const fragen = parseFragen(kpFragen.value, jsonKey);
kpJsonKey.value = jsonKey;
if (pendingManualRect) {
const name = kpFieldId.value.trim();
if (!name) { kpFieldId.focus(); return; }
const entry: FieldEntry = {
id: `manual-${Date.now()}`,
fieldName: name,
jsonKey,
rect: pendingManualRect,
page: currentPage,
type: "manual",
label: label || undefined,
antwortFormat,
fragen,
};
fields.push(entry);
selectedFieldId = entry.id;
pendingManualRect = null;
} else if (selectedFieldId) {
const field = fields.find(f => f.id === selectedFieldId);
if (field) {
field.jsonKey = jsonKey;
field.label = label || undefined;
field.antwortFormat = antwortFormat;
field.fragen = fragen;
}
}
persist();
hideKeyPanel();
renderOverlay();
renderFieldList();
});
kpCancel.addEventListener("click", () => {
pendingManualRect = null;
selectedFieldId = null;
hideKeyPanel();
renderOverlay();
});
kpRemove.addEventListener("click", () => {
if (!selectedFieldId) return;
fields = fields.filter(f => f.id !== selectedFieldId);
selectedFieldId = null;
persist();
hideKeyPanel();
renderOverlay();
renderFieldList();
});
kpJsonKey.addEventListener("keydown", e => {
if (e.key === "Enter") kpAssign.click();
if (e.key === "Escape") kpCancel.click();
});
kpFieldId.addEventListener("keydown", e => {
if (e.key === "Enter") kpJsonKey.focus();
});
// ─── Global keyboard shortcuts ────────────────────────────────────────────────
document.addEventListener("keydown", e => {
if (e.key === "Escape") {
if (isDrawMode) { exitDrawMode(); return; }
if (!keyPanel.classList.contains("hidden")) kpCancel.click();
}
});
// ─── Field list sidebar ───────────────────────────────────────────────────────
function renderFieldList() {
const mapped = fields.filter(f => f.jsonKey).length;
fieldCount.textContent = `${mapped} / ${fields.length} mapped`;
if (fields.length === 0) {
fieldList.innerHTML = '<p class="field-empty">No fields detected.<br>Use "Draw Field" to add manually.</p>';
return;
}
const byPage = new Map<number, FieldEntry[]>();
for (const f of fields) {
if (!byPage.has(f.page)) byPage.set(f.page, []);
byPage.get(f.page)!.push(f);
}
fieldList.innerHTML = "";
for (const [page, pf] of [...byPage.entries()].sort((a, b) => a[0] - b[0])) {
const label = document.createElement("div");
label.className = "field-page-label";
label.textContent = `Page ${page}`;
fieldList.appendChild(label);
for (const field of pf) {
const item = document.createElement("div");
item.className = "field-item" +
(field.id === selectedFieldId ? " selected" : "") +
(field.jsonKey ? " mapped" : "");
item.dataset.id = field.id;
item.innerHTML = `
<span class="fi-name">${field.fieldName}</span>
<span class="fi-key">${field.jsonKey || "unmapped"}</span>
`;
item.addEventListener("click", async () => {
if (field.page !== currentPage) {
currentPage = field.page;
await renderPage(currentPage);
}
selectField(field.id);
});
fieldList.appendChild(item);
}
}
}
// ─── Save ─────────────────────────────────────────────────────────────────────
function setNested(obj: Record<string, unknown>, path: string, value: unknown): void {
const parts = path.split(".");
let cur: Record<string, unknown> = obj;
for (let i = 0; i < parts.length - 1; i++) {
const key = parts[i]!;
if (typeof cur[key] !== "object" || cur[key] === null) cur[key] = {};
cur = cur[key] as Record<string, unknown>;
}
cur[parts[parts.length - 1]!] = value;
}
saveBtn.addEventListener("click", async () => {
const template: Record<string, unknown> = {};
for (const f of fields) {
if (!f.jsonKey) continue;
const isAntwortBlock = f.jsonKey.startsWith("fragen.") || f.jsonKey.startsWith("kriterium.");
setNested(
template,
f.jsonKey,
isAntwortBlock
? {
fieldName: f.fieldName,
label: f.label,
antwortFormat: f.antwortFormat ?? "einzelfrage",
fragen: f.fragen ?? [],
}
: f.fieldName,
);
}
try {
await fetch("/api/save", {
method: "POST",
headers: csrfHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(template),
});
const orig = saveBtn.textContent;
saveBtn.textContent = "Saved ✓";
setTimeout(() => { saveBtn.textContent = orig; }, 2000);
} catch (err) {
console.error("Save failed:", err);
}
});
function persist() {
if (!currentFilename) return;
localStorage.setItem(`tb:${currentFilename}`, JSON.stringify(fields));
}