Files
muh/frontend/src/pages/InvoiceTemplatePage.tsx
Sven Carstensen f7226604e2 feat: Split customer address into separate fields in invoice template
Replace combined 'customer-address' with 4 separate elements:
- customer-street (Straße)
- customer-house-number (Hausnummer)
- customer-postal-code (PLZ)
- customer-city (Ort)

Update starter layout to use new separate fields with appropriate positioning.
2026-03-18 11:51:44 +01:00

2196 lines
70 KiB
TypeScript

import {
useEffect,
useState,
useRef,
type ChangeEvent as ReactChangeEvent,
type DragEvent,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
} from "react";
import { ApiError, apiGet, apiPut } from "../lib/api";
import { useSession } from "../lib/session";
import type { UserOption } from "../lib/types";
const CANVAS_WIDTH = 794;
const CANVAS_HEIGHT = 1123;
const CANVAS_ASPECT_RATIO = CANVAS_WIDTH / CANVAS_HEIGHT;
const GRID_SIZE = 5;
const DRAG_DATA_TYPE = "application/x-muh-invoice-template";
const PDF_PAGE_WIDTH = 595.28;
const PDF_PAGE_HEIGHT = 841.89;
const PDF_SCALE = PDF_PAGE_WIDTH / CANVAS_WIDTH;
const PDF_TEXT_PADDING_X = 12;
const PDF_TEXT_PADDING_Y = 10;
const PDF_LINE_HEIGHT = 1.35;
const CANVAS_BOTTOM_GAP = 10;
const MIN_ELEMENT_WIDTH = 100;
const MIN_MEDIA_WIDTH = 40;
const MIN_MEDIA_HEIGHT = 40;
const MIN_LINE_THICKNESS = 1;
const MIN_LINE_LENGTH = 40;
const MIN_FONT_SIZE = 12;
const MAX_FONT_SIZE = 44;
const INVOICE_LOCKED_TEXT_PALETTE_IDS = new Set([
"invoice-number",
"invoice-date",
"invoice-due-date",
"customer-number",
"customer-name",
"customer-street",
"customer-house-number",
"customer-postal-code",
"customer-city",
"customer-email",
"customer-phone",
"invoice-subtotal",
"invoice-tax",
"invoice-total",
"payment-terms",
"bank-details",
"issuer-name",
"issuer-address",
"issuer-contact",
]);
type ElementKind = "text" | "line" | "image";
type LineOrientation = "horizontal" | "vertical";
type TextAlign = "left" | "center" | "right";
type FontWeight = 400 | 500 | 600 | 700;
type PaletteCategory = string;
interface PaletteItem {
id: string;
category: PaletteCategory;
label: string;
description: string;
kind?: ElementKind;
width: number;
height?: number;
fontSize: number;
fontWeight: FontWeight;
textAlign: TextAlign;
lineOrientation?: LineOrientation;
defaultContent: (user: UserOption | null) => string;
}
interface TemplateElement {
id: string;
paletteId: string;
kind: ElementKind;
label: string;
content: string;
x: number;
y: number;
width: number;
height?: number;
fontSize: number;
fontWeight: FontWeight;
textAlign: TextAlign;
lineOrientation?: LineOrientation;
imageSrc?: string | null;
imageNaturalWidth?: number | null;
imageNaturalHeight?: number | null;
}
interface CanvasViewport {
height: number;
scale: number;
syncPanelHeight: boolean;
width: number;
}
interface PdfImageResource {
body: string;
elementId: string;
name: string;
pixelHeight: number;
pixelWidth: number;
}
interface UploadedImageAsset {
dataUrl: string;
height: number;
width: number;
}
interface InvoiceTemplateResponse {
elements: unknown;
stored: boolean;
updatedAt: string | null;
}
type DragPayload =
| {
kind: "palette";
paletteId: string;
}
| {
kind: "element";
elementId: string;
};
const CP1252_MAP: Record<number, number> = {
338: 140,
339: 156,
352: 138,
353: 154,
376: 159,
381: 142,
382: 158,
402: 131,
710: 136,
732: 152,
8211: 150,
8212: 151,
8216: 145,
8217: 146,
8218: 130,
8220: 147,
8221: 148,
8222: 132,
8224: 134,
8225: 135,
8226: 149,
8230: 133,
8240: 137,
8249: 139,
8250: 155,
8364: 128,
8482: 153,
};
const INVOICE_PALETTE_ITEMS: PaletteItem[] = [
{
id: "invoice-title",
category: "invoice-header",
label: "Rechnungstitel",
description: "Titel der Rechnung",
width: 200,
fontSize: 28,
fontWeight: 700,
textAlign: "left",
defaultContent: () => "Rechnung",
},
{
id: "invoice-number",
category: "invoice-header",
label: "Rechnungsnummer",
description: "Automatische Rechnungsnummer",
width: 200,
fontSize: 16,
fontWeight: 600,
textAlign: "left",
defaultContent: () => "Rechnungsnr.: R-2026-001",
},
{
id: "invoice-date",
category: "invoice-header",
label: "Rechnungsdatum",
description: "Datum der Rechnungserstellung",
width: 200,
fontSize: 16,
fontWeight: 400,
textAlign: "left",
defaultContent: () => `Datum: ${new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(new Date())}`,
},
{
id: "invoice-due-date",
category: "invoice-header",
label: "Fälligkeitsdatum",
description: "Zahlungsfälligkeit",
width: 200,
fontSize: 16,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "Fällig bis: 15.04.2026",
},
{
id: "customer-number",
category: "customer-data",
label: "Kundennummer",
description: "Kundennummer",
width: 200,
fontSize: 14,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "Kunden-Nr.: K-12345",
},
{
id: "customer-name",
category: "customer-data",
label: "Kundenname",
description: "Name des Kunden",
width: 280,
fontSize: 16,
fontWeight: 600,
textAlign: "left",
defaultContent: () => "VoS, Dirk Schwissel",
},
{
id: "customer-street",
category: "customer-data",
label: "Kunden-Straße",
description: "Straße des Kunden",
width: 200,
fontSize: 14,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "Musterstraße",
},
{
id: "customer-house-number",
category: "customer-data",
label: "Kunden-Hausnummer",
description: "Hausnummer des Kunden",
width: 80,
fontSize: 14,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "123",
},
{
id: "customer-postal-code",
category: "customer-data",
label: "Kunden-PLZ",
description: "Postleitzahl des Kunden",
width: 80,
fontSize: 14,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "12345",
},
{
id: "customer-city",
category: "customer-data",
label: "Kunden-Ort",
description: "Ort des Kunden",
width: 200,
fontSize: 14,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "Musterstadt",
},
{
id: "customer-email",
category: "customer-data",
label: "Kunden-E-Mail",
description: "E-Mail-Adresse des Kunden",
width: 250,
fontSize: 14,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "E-Mail: kunde@example.com",
},
{
id: "customer-phone",
category: "customer-data",
label: "Kunden-Telefon",
description: "Telefonnummer des Kunden",
width: 200,
fontSize: 14,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "Tel.: +49 123 456789",
},
{
id: "issuer-name",
category: "issuer-data",
label: "Aussteller-Name",
description: "Name des Rechnungsausstellers",
width: 280,
fontSize: 16,
fontWeight: 700,
textAlign: "left",
defaultContent: (user) => user?.companyName ?? "Ihr Unternehmen",
},
{
id: "issuer-address",
category: "issuer-data",
label: "Aussteller-Adresse",
description: "Adresse des Rechnungsausstellers",
width: 280,
fontSize: 14,
fontWeight: 400,
textAlign: "left",
defaultContent: (user) =>
user?.address ??
`${user?.street ?? ""} ${user?.houseNumber ?? ""}\n${user?.postalCode ?? ""} ${user?.city ?? ""}`,
},
{
id: "issuer-contact",
category: "issuer-data",
label: "Aussteller-Kontakt",
description: "Kontaktdaten des Rechnungsausstellers",
width: 280,
fontSize: 14,
fontWeight: 400,
textAlign: "left",
defaultContent: (user) =>
`Tel.: ${user?.phoneNumber ?? ""}\nE-Mail: ${user?.email ?? ""}`,
},
{
id: "invoice-subtotal",
category: "invoice-totals",
label: "Zwischensumme",
description: "Nettobetrag",
width: 200,
fontSize: 16,
fontWeight: 600,
textAlign: "right",
defaultContent: () => "Zwischensumme: 95,00 €",
},
{
id: "invoice-tax",
category: "invoice-totals",
label: "Mehrwertsteuer",
description: "MwSt-Betrag",
width: 200,
fontSize: 16,
fontWeight: 400,
textAlign: "right",
defaultContent: () => "MwSt. (19%): 18,05 €",
},
{
id: "invoice-total",
category: "invoice-totals",
label: "Gesamtbetrag",
description: "Bruttobetrag",
width: 200,
fontSize: 20,
fontWeight: 700,
textAlign: "right",
defaultContent: () => "Gesamt: 113,05 €",
},
{
id: "payment-terms",
category: "invoice-footer",
label: "Zahlungsbedingungen",
description: "Zahlungsziel und -bedingungen",
width: 400,
fontSize: 14,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "Zahlungsbedingungen: Zahlung innerhalb von 14 Tagen ohne Abzug.",
},
{
id: "bank-details",
category: "invoice-footer",
label: "Bankverbindung",
description: "Bankdaten für die Zahlung",
width: 400,
fontSize: 14,
fontWeight: 400,
textAlign: "left",
defaultContent: () =>
"Bankverbindung:\nIBAN: DE12 3456 7890 1234 5678 90\nBIC: ABCDEFGHXXX\nBank: Musterbank",
},
{
id: "free-text",
category: "free-elements",
label: "Freitext",
description: "Beliebig editierbarer Text",
width: 220,
fontSize: 16,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "Freitext",
},
{
id: "line",
category: "free-elements",
label: "Linie",
description: "Linie mit umschaltbarer Ausrichtung",
kind: "line",
width: 260,
height: 3,
fontSize: 16,
fontWeight: 400,
textAlign: "left",
lineOrientation: "horizontal",
defaultContent: () => "",
},
{
id: "image",
category: "free-elements",
label: "Bild",
description: "Logo, Kopfgrafik oder Gestaltungselement",
kind: "image",
width: 180,
height: 120,
fontSize: 16,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "Bild",
},
];
const INVOICE_PALETTE_GROUPS: Array<{ category: PaletteCategory; title: string }> = [
{ category: "invoice-header", title: "Rechnungskopf" },
{ category: "customer-data", title: "Kundendaten" },
{ category: "issuer-data", title: "Aussteller" },
{ category: "invoice-totals", title: "Beträge" },
{ category: "invoice-footer", title: "Fußbereich" },
{ category: "free-elements", title: "Freie Elemente" },
];
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(Math.max(value, minimum), maximum);
}
function snapToGrid(value: number) {
return Math.round(value / GRID_SIZE) * GRID_SIZE;
}
function formatPdfNumber(value: number) {
const normalized = Number(value.toFixed(2));
if (Number.isInteger(normalized)) {
return String(normalized);
}
return normalized.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
}
function measureTextWidth(text: string, fontSize: number, fontWeight: FontWeight) {
if (typeof document === "undefined") {
return text.length * fontSize * 0.56;
}
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) {
return text.length * fontSize * 0.56;
}
context.font = `${fontWeight >= 600 ? 700 : 400} ${fontSize}px Arial`;
return context.measureText(text).width;
}
function splitLongToken(token: string, maxWidth: number, fontSize: number, fontWeight: FontWeight) {
const segments: string[] = [];
let currentSegment = "";
for (const character of token) {
const candidate = `${currentSegment}${character}`;
if (currentSegment && measureTextWidth(candidate, fontSize, fontWeight) > maxWidth) {
segments.push(currentSegment);
currentSegment = character;
continue;
}
currentSegment = candidate;
}
if (currentSegment) {
segments.push(currentSegment);
}
return segments;
}
function wrapTextLines(text: string, maxWidth: number, fontSize: number, fontWeight: FontWeight) {
const paragraphs = text.split(/\r?\n/);
const wrappedLines: string[] = [];
for (const paragraph of paragraphs) {
const words = paragraph.trim().split(/\s+/).filter(Boolean);
if (!words.length) {
wrappedLines.push("");
continue;
}
let currentLine = "";
for (const word of words) {
const fragments =
measureTextWidth(word, fontSize, fontWeight) > maxWidth
? splitLongToken(word, maxWidth, fontSize, fontWeight)
: [word];
for (const fragment of fragments) {
const candidate = currentLine ? `${currentLine} ${fragment}` : fragment;
if (!currentLine || measureTextWidth(candidate, fontSize, fontWeight) <= maxWidth) {
currentLine = candidate;
continue;
}
wrappedLines.push(currentLine);
currentLine = fragment;
}
}
if (currentLine) {
wrappedLines.push(currentLine);
}
}
return wrappedLines.length ? wrappedLines : [""];
}
function encodePdfText(text: string) {
const bytes: number[] = [];
for (const character of text) {
const codePoint = character.codePointAt(0) ?? 63;
if (CP1252_MAP[codePoint]) {
bytes.push(CP1252_MAP[codePoint]);
continue;
}
if (codePoint >= 0 && codePoint <= 255) {
bytes.push(codePoint);
continue;
}
bytes.push(63);
}
return bytes.map((value) => value.toString(16).padStart(2, "0")).join("").toUpperCase();
}
function getDefaultElementHeight(
kind: ElementKind,
fontSize: number,
lineOrientation?: LineOrientation,
) {
if (kind === "line") {
return lineOrientation === "vertical" ? 180 : 3;
}
if (kind === "image") {
return 120;
}
return Math.max(fontSize + 16, 28);
}
function getMinimumWidthForElement(kind: ElementKind, lineOrientation?: LineOrientation) {
if (kind === "line") {
return lineOrientation === "vertical" ? MIN_LINE_THICKNESS : MIN_LINE_LENGTH;
}
if (kind === "image") {
return MIN_MEDIA_WIDTH;
}
return MIN_ELEMENT_WIDTH;
}
function getMinimumHeightForElement(
kind: ElementKind,
fontSize: number,
lineOrientation?: LineOrientation,
) {
if (kind === "line") {
return lineOrientation === "vertical" ? MIN_LINE_LENGTH : MIN_LINE_THICKNESS;
}
if (kind === "image") {
return MIN_MEDIA_HEIGHT;
}
return getDefaultElementHeight(kind, fontSize, lineOrientation);
}
function getElementHeight(
element: Pick<TemplateElement, "fontSize" | "height" | "kind" | "lineOrientation">,
) {
return Math.max(
1,
Math.round(
element.height ?? getDefaultElementHeight(element.kind, element.fontSize, element.lineOrientation),
),
);
}
function fitContainedSize(
containerWidth: number,
containerHeight: number,
assetWidth: number,
assetHeight: number,
) {
if (assetWidth <= 0 || assetHeight <= 0) {
return {
height: containerHeight,
offsetX: 0,
offsetY: 0,
width: containerWidth,
};
}
const scale = Math.min(containerWidth / assetWidth, containerHeight / assetHeight);
const width = assetWidth * scale;
const height = assetHeight * scale;
return {
height,
offsetX: (containerWidth - width) / 2,
offsetY: (containerHeight - height) / 2,
width,
};
}
function decodeBase64DataUrl(dataUrl: string) {
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
if (!match) {
return null;
}
const [, mimeType, base64Payload] = match;
const binary = atob(base64Payload);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return {
bytes,
mimeType,
};
}
function bytesToHex(bytes: Uint8Array) {
let hex = "";
for (const value of bytes) {
hex += value.toString(16).padStart(2, "0").toUpperCase();
}
return hex;
}
function createPdfImageResources(elements: TemplateElement[]) {
const imageElements = elements.filter(
(element) => element.kind === "image" && typeof element.imageSrc === "string" && element.imageSrc,
);
return imageElements.reduce<PdfImageResource[]>((resources, element, index) => {
const decoded = decodeBase64DataUrl(element.imageSrc ?? "");
if (!decoded || !/image\/jpe?g/i.test(decoded.mimeType)) {
return resources;
}
const hexPayload = `${bytesToHex(decoded.bytes)}>`;
resources.push({
body:
`<< /Type /XObject /Subtype /Image /Width ${Math.max(1, Math.round(element.imageNaturalWidth ?? element.width))}` +
` /Height ${Math.max(1, Math.round(element.imageNaturalHeight ?? getElementHeight(element)))}` +
" /ColorSpace /DeviceRGB /BitsPerComponent 8" +
" /Filter [/ASCIIHexDecode /DCTDecode]" +
` /Length ${hexPayload.length} >>
stream
${hexPayload}
endstream`,
elementId: element.id,
name: `/Im${index + 1}`,
pixelHeight: Math.max(1, Math.round(element.imageNaturalHeight ?? getElementHeight(element))),
pixelWidth: Math.max(1, Math.round(element.imageNaturalWidth ?? element.width)),
});
return resources;
}, []);
}
function createPdfContentStream(
elements: TemplateElement[],
imageResources: Map<string, PdfImageResource>,
) {
const commands = ["0 g"];
for (const element of elements) {
if (element.kind === "line") {
const elementHeight = getElementHeight(element);
const pdfX = element.x * PDF_SCALE;
const pdfY = PDF_PAGE_HEIGHT - (element.y + elementHeight) * PDF_SCALE;
const pdfWidth = element.width * PDF_SCALE;
const pdfHeight = elementHeight * PDF_SCALE;
commands.push(
`${formatPdfNumber(pdfX)} ${formatPdfNumber(pdfY)} ${formatPdfNumber(pdfWidth)} ${formatPdfNumber(pdfHeight)} re f`,
);
continue;
}
if (element.kind === "image") {
const imageResource = imageResources.get(element.id);
if (!imageResource) {
continue;
}
const boxHeight = getElementHeight(element);
const fitted = fitContainedSize(
element.width,
boxHeight,
imageResource.pixelWidth,
imageResource.pixelHeight,
);
const pdfX = (element.x + fitted.offsetX) * PDF_SCALE;
const pdfY =
PDF_PAGE_HEIGHT - (element.y + fitted.offsetY + fitted.height) * PDF_SCALE;
const pdfWidth = fitted.width * PDF_SCALE;
const pdfHeight = fitted.height * PDF_SCALE;
commands.push("q");
commands.push(
`${formatPdfNumber(pdfWidth)} 0 0 ${formatPdfNumber(pdfHeight)} ${formatPdfNumber(pdfX)} ${formatPdfNumber(pdfY)} cm`,
);
commands.push(`${imageResource.name} Do`);
commands.push("Q");
continue;
}
const fontName = element.fontWeight >= 600 ? "/F2" : "/F1";
const fontSize = element.fontSize * PDF_SCALE;
const lineHeight = element.fontSize * PDF_LINE_HEIGHT;
const textStartX = element.x + PDF_TEXT_PADDING_X;
const textWidth = Math.max(24, element.width - PDF_TEXT_PADDING_X * 2);
const wrappedLines = wrapTextLines(
element.content || "",
textWidth,
element.fontSize,
element.fontWeight,
);
wrappedLines.forEach((line, index) => {
if (!line) {
return;
}
const renderedLineWidth = measureTextWidth(line, element.fontSize, element.fontWeight);
let textX = textStartX;
if (element.textAlign === "center") {
textX += Math.max(0, (textWidth - renderedLineWidth) / 2);
} else if (element.textAlign === "right") {
textX += Math.max(0, textWidth - renderedLineWidth);
}
const baselineY = element.y + PDF_TEXT_PADDING_Y + element.fontSize + index * lineHeight;
const pdfX = textX * PDF_SCALE;
const pdfY = PDF_PAGE_HEIGHT - baselineY * PDF_SCALE;
commands.push("BT");
commands.push(`${fontName} ${formatPdfNumber(fontSize)} Tf`);
commands.push(`1 0 0 1 ${formatPdfNumber(pdfX)} ${formatPdfNumber(pdfY)} Tm`);
commands.push(`<${encodePdfText(line)}> Tj`);
commands.push("ET");
});
}
return commands.join("\n");
}
function createPdfBlob(elements: TemplateElement[]) {
const imageResources = createPdfImageResources(elements);
const imageResourceMap = new Map(imageResources.map((resource) => [resource.elementId, resource]));
const contentStream = createPdfContentStream(elements, imageResourceMap);
const imageObjectNumberStart = 6;
const contentObjectNumber = imageObjectNumberStart + imageResources.length;
const xObjectResources = imageResources.length
? ` /XObject << ${imageResources
.map((resource, index) => `${resource.name} ${imageObjectNumberStart + index} 0 R`)
.join(" ")} >>`
: "";
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Count 1 /Kids [3 0 R] >>",
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${formatPdfNumber(PDF_PAGE_WIDTH)} ${formatPdfNumber(PDF_PAGE_HEIGHT)}] /Resources << /Font << /F1 4 0 R /F2 5 0 R >>${xObjectResources} >> /Contents ${contentObjectNumber} 0 R >>`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>",
...imageResources.map((resource) => resource.body),
`<< /Length ${contentStream.length} >>\nstream\n${contentStream}\nendstream`,
];
let pdf = "%PDF-1.4\n";
const offsets = [0];
objects.forEach((body, index) => {
offsets.push(pdf.length);
pdf += `${index + 1} 0 obj\n${body}\nendobj\n`;
});
const xrefOffset = pdf.length;
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += "0000000000 65535 f \n";
for (let index = 1; index <= objects.length; index += 1) {
pdf += `${String(offsets[index]).padStart(10, "0")} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
return new Blob([pdf], { type: "application/pdf" });
}
function generateId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `invoice-template-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function normalizeFontWeight(value: unknown): FontWeight {
return value === 500 || value === 600 || value === 700 ? value : 400;
}
function normalizeTextAlign(value: unknown): TextAlign {
return value === "center" || value === "right" ? value : "left";
}
function normalizeElementKind(value: unknown): ElementKind {
return value === "line" || value === "image" ? value : "text";
}
function normalizeLineOrientation(value: unknown): LineOrientation {
return value === "vertical" ? "vertical" : "horizontal";
}
function normalizeElement(element: TemplateElement): TemplateElement {
const kind = normalizeElementKind(element.kind);
const lineOrientation = kind === "line" ? normalizeLineOrientation(element.lineOrientation) : undefined;
const fontSize = clamp(Math.round(element.fontSize), MIN_FONT_SIZE, MAX_FONT_SIZE);
let width = Math.round(element.width);
let height = getElementHeight({
fontSize,
height: element.height,
kind,
lineOrientation,
});
if (kind === "line") {
width = clamp(width, getMinimumWidthForElement(kind, lineOrientation), CANVAS_WIDTH);
height = clamp(height, getMinimumHeightForElement(kind, fontSize, lineOrientation), CANVAS_HEIGHT);
} else if (kind === "image") {
width = clamp(width, getMinimumWidthForElement(kind), CANVAS_WIDTH - 32);
height = clamp(height, getMinimumHeightForElement(kind, fontSize), CANVAS_HEIGHT - 32);
} else {
width = clamp(width, getMinimumWidthForElement(kind), CANVAS_WIDTH - 32);
height = getDefaultElementHeight(kind, fontSize, lineOrientation);
}
const x = clamp(snapToGrid(element.x), 0, CANVAS_WIDTH - width);
const y = clamp(
snapToGrid(element.y),
0,
kind === "text" ? CANVAS_HEIGHT - 40 : CANVAS_HEIGHT - height,
);
const imageNaturalWidth =
kind === "image" && typeof element.imageNaturalWidth === "number" && element.imageNaturalWidth > 0
? Math.round(element.imageNaturalWidth)
: null;
const imageNaturalHeight =
kind === "image" && typeof element.imageNaturalHeight === "number" && element.imageNaturalHeight > 0
? Math.round(element.imageNaturalHeight)
: null;
return {
...element,
kind,
width,
height: kind === "text" ? undefined : height,
fontSize,
x,
y,
fontWeight: normalizeFontWeight(element.fontWeight),
textAlign: normalizeTextAlign(element.textAlign),
lineOrientation,
imageSrc: kind === "image" ? element.imageSrc ?? null : undefined,
imageNaturalWidth,
imageNaturalHeight,
};
}
function findPaletteItem(paletteItems: PaletteItem[], paletteId: string) {
return paletteItems.find((entry) => entry.id === paletteId) ?? null;
}
function requirePaletteItem(paletteItems: PaletteItem[], paletteId: string) {
const paletteItem = findPaletteItem(paletteItems, paletteId);
if (!paletteItem) {
throw new Error(`Palette item ${paletteId} is not configured.`);
}
return paletteItem;
}
function getPalettePreviewText(paletteItem: PaletteItem, user: UserOption | null) {
if (paletteItem.kind === "image") {
return "Bild auswaehlen";
}
if (paletteItem.kind === "line") {
return "Linie";
}
return paletteItem.defaultContent(user);
}
function isElementContentEditable(
element: Pick<TemplateElement, "kind" | "paletteId">,
lockedTextPaletteIds: ReadonlySet<string>,
) {
return element.kind === "text" && !lockedTextPaletteIds.has(element.paletteId);
}
function createElementFromPalette(
paletteItem: PaletteItem,
user: UserOption | null,
position: { x: number; y: number },
): TemplateElement {
return normalizeElement({
id: generateId(),
paletteId: paletteItem.id,
kind: paletteItem.kind ?? "text",
label: paletteItem.label,
content: paletteItem.defaultContent(user),
x: position.x,
y: position.y,
width: paletteItem.width,
height: paletteItem.height,
fontSize: paletteItem.fontSize,
fontWeight: paletteItem.fontWeight,
textAlign: paletteItem.textAlign,
lineOrientation: paletteItem.lineOrientation,
imageSrc: null,
imageNaturalWidth: null,
imageNaturalHeight: null,
});
}
function createInvoiceStarterLayout(user: UserOption | null, paletteItems: PaletteItem[]) {
const horizontalLine = (x: number, y: number, width: number) =>
normalizeElement({
...createElementFromPalette(requirePaletteItem(paletteItems, "line"), user, { x, y }),
width,
});
return [
createElementFromPalette(requirePaletteItem(paletteItems, "issuer-name"), user, { x: 56, y: 56 }),
createElementFromPalette(requirePaletteItem(paletteItems, "issuer-address"), user, { x: 56, y: 86 }),
createElementFromPalette(requirePaletteItem(paletteItems, "issuer-contact"), user, { x: 56, y: 130 }),
createElementFromPalette(requirePaletteItem(paletteItems, "invoice-title"), user, { x: 480, y: 56 }),
createElementFromPalette(requirePaletteItem(paletteItems, "invoice-number"), user, { x: 480, y: 96 }),
createElementFromPalette(requirePaletteItem(paletteItems, "invoice-date"), user, { x: 480, y: 122 }),
createElementFromPalette(requirePaletteItem(paletteItems, "invoice-due-date"), user, { x: 480, y: 148 }),
horizontalLine(56, 200, 646),
createElementFromPalette(requirePaletteItem(paletteItems, "customer-name"), user, { x: 56, y: 220 }),
createElementFromPalette(requirePaletteItem(paletteItems, "customer-street"), user, { x: 56, y: 246 }),
createElementFromPalette(requirePaletteItem(paletteItems, "customer-house-number"), user, { x: 260, y: 246 }),
createElementFromPalette(requirePaletteItem(paletteItems, "customer-postal-code"), user, { x: 56, y: 270 }),
createElementFromPalette(requirePaletteItem(paletteItems, "customer-city"), user, { x: 140, y: 270 }),
createElementFromPalette(requirePaletteItem(paletteItems, "customer-number"), user, { x: 56, y: 290 }),
horizontalLine(56, 330, 646),
createElementFromPalette(requirePaletteItem(paletteItems, "invoice-subtotal"), user, { x: 500, y: 460 }),
createElementFromPalette(requirePaletteItem(paletteItems, "invoice-tax"), user, { x: 500, y: 486 }),
createElementFromPalette(requirePaletteItem(paletteItems, "invoice-total"), user, { x: 500, y: 520 }),
horizontalLine(56, 560, 646),
createElementFromPalette(requirePaletteItem(paletteItems, "payment-terms"), user, { x: 56, y: 580 }),
createElementFromPalette(requirePaletteItem(paletteItems, "bank-details"), user, { x: 56, y: 620 }),
];
}
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error("Bild konnte nicht geladen werden."));
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
return;
}
reject(new Error("Bild konnte nicht geladen werden."));
};
reader.readAsDataURL(file);
});
}
function loadImageAsset(dataUrl: string) {
return new Promise<UploadedImageAsset>((resolve, reject) => {
const image = new Image();
image.onerror = () => reject(new Error("Bild konnte nicht gelesen werden."));
image.onload = () =>
resolve({
dataUrl,
height: image.naturalHeight,
width: image.naturalWidth,
});
image.src = dataUrl;
});
}
async function convertImageFileToJpeg(file: File) {
const source = await readFileAsDataUrl(file);
const asset = await loadImageAsset(source);
const canvas = document.createElement("canvas");
canvas.width = asset.width;
canvas.height = asset.height;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Bild konnte nicht verarbeitet werden.");
}
const image = await loadImageAsset(source);
context.fillStyle = "#ffffff";
context.fillRect(0, 0, canvas.width, canvas.height);
const renderImage = new Image();
await new Promise<void>((resolve, reject) => {
renderImage.onerror = () => reject(new Error("Bild konnte nicht verarbeitet werden."));
renderImage.onload = () => {
context.drawImage(renderImage, 0, 0, canvas.width, canvas.height);
resolve();
};
renderImage.src = image.dataUrl;
});
return {
dataUrl: canvas.toDataURL("image/jpeg", 0.92),
height: asset.height,
width: asset.width,
};
}
function normalizeTemplateElements(raw: unknown) {
if (!Array.isArray(raw)) {
return null;
}
const elements = raw
.map((entry) => {
if (!entry || typeof entry !== "object") {
return null;
}
const candidate = entry as Partial<TemplateElement>;
if (
typeof candidate.id !== "string" ||
typeof candidate.paletteId !== "string" ||
typeof candidate.label !== "string" ||
typeof candidate.content !== "string" ||
typeof candidate.x !== "number" ||
typeof candidate.y !== "number" ||
typeof candidate.width !== "number" ||
typeof candidate.fontSize !== "number"
) {
return null;
}
return normalizeElement({
id: candidate.id,
paletteId: candidate.paletteId,
kind: normalizeElementKind(candidate.kind),
label: candidate.label,
content: candidate.content,
x: candidate.x,
y: candidate.y,
width: candidate.width,
height: typeof candidate.height === "number" ? candidate.height : undefined,
fontSize: candidate.fontSize,
fontWeight: normalizeFontWeight(candidate.fontWeight),
textAlign: normalizeTextAlign(candidate.textAlign),
lineOrientation: normalizeLineOrientation(candidate.lineOrientation),
imageSrc: typeof candidate.imageSrc === "string" ? candidate.imageSrc : null,
imageNaturalWidth:
typeof candidate.imageNaturalWidth === "number" ? candidate.imageNaturalWidth : null,
imageNaturalHeight:
typeof candidate.imageNaturalHeight === "number" ? candidate.imageNaturalHeight : null,
});
})
.filter((entry): entry is TemplateElement => entry !== null);
return elements;
}
function formatTemplateTimestamp(value: string | null) {
if (!value) {
return null;
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return null;
}
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(parsed);
}
function readDragPayload(event: DragEvent<HTMLElement>): DragPayload | null {
const raw = event.dataTransfer.getData(DRAG_DATA_TYPE);
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as DragPayload;
if (parsed.kind === "palette" && typeof parsed.paletteId === "string") {
return parsed;
}
if (parsed.kind === "element" && typeof parsed.elementId === "string") {
return parsed;
}
return null;
} catch {
return null;
}
}
export default function InvoiceTemplatePage() {
const { user } = useSession();
const pageRef = useRef<HTMLDivElement | null>(null);
const cardRef = useRef<HTMLElement | null>(null);
const canvasRef = useRef<HTMLDivElement | null>(null);
const canvasShellRef = useRef<HTMLDivElement | null>(null);
const dragDepthRef = useRef(0);
const mouseDragCleanupRef = useRef<(() => void) | null>(null);
const moveOffsetRef = useRef<{ x: number; y: number } | null>(null);
const [elements, setElements] = useState<TemplateElement[]>(() =>
createInvoiceStarterLayout(user, INVOICE_PALETTE_ITEMS),
);
const [selectedElementId, setSelectedElementId] = useState<string | null>(null);
const [isCanvasActive, setIsCanvasActive] = useState(false);
const [draggingElementId, setDraggingElementId] = useState<string | null>(null);
const [resizingElementId, setResizingElementId] = useState<string | null>(null);
const [pdfError, setPdfError] = useState<string | null>(null);
const [templateError, setTemplateError] = useState<string | null>(null);
const [templateUpdatedAt, setTemplateUpdatedAt] = useState<string | null>(null);
const [isTemplateLoading, setIsTemplateLoading] = useState(false);
const [isTemplateSaving, setIsTemplateSaving] = useState(false);
const [isTemplateApiAvailable, setIsTemplateApiAvailable] = useState(true);
const [pdfPreviewUrl, setPdfPreviewUrl] = useState<string | null>(null);
const [pageViewportHeight, setPageViewportHeight] = useState<number | null>(null);
const [canvasViewport, setCanvasViewport] = useState<CanvasViewport>({
height: CANVAS_HEIGHT,
scale: 1,
syncPanelHeight: false,
width: CANVAS_WIDTH,
});
const selectedElement = elements.find((entry) => entry.id === selectedElementId) ?? null;
const selectedElementHeight = selectedElement ? getElementHeight(selectedElement) : null;
const selectedElementContentEditable = selectedElement
? isElementContentEditable(selectedElement, INVOICE_LOCKED_TEXT_PALETTE_IDS)
: false;
const selectedElementMinimumWidth = selectedElement
? getMinimumWidthForElement(selectedElement.kind, selectedElement.lineOrientation)
: MIN_ELEMENT_WIDTH;
const selectedElementMinimumHeight = selectedElement
? getMinimumHeightForElement(
selectedElement.kind,
selectedElement.fontSize,
selectedElement.lineOrientation,
)
: MIN_MEDIA_HEIGHT;
const templateTimestampLabel = formatTemplateTimestamp(templateUpdatedAt);
const emptyCanvasMessage = "Ziehen Sie links ein Element auf diese Fläche, um Ihre Rechnungsvorlage zu starten.";
useEffect(() => {
if (!user) {
const starterLayout = createInvoiceStarterLayout(null, INVOICE_PALETTE_ITEMS);
setElements(starterLayout);
setSelectedElementId(starterLayout[0]?.id ?? null);
setResizingElementId(null);
setIsTemplateApiAvailable(true);
setIsTemplateLoading(false);
setTemplateUpdatedAt(null);
setTemplateError(null);
return;
}
let cancelled = false;
setTemplateError(null);
setIsTemplateLoading(true);
void apiGet<InvoiceTemplateResponse>("/session/invoice-template")
.then((response) => {
if (cancelled) {
return;
}
setIsTemplateApiAvailable(true);
setTemplateUpdatedAt(response.updatedAt);
if (!response.stored) {
const starterLayout = createInvoiceStarterLayout(user, INVOICE_PALETTE_ITEMS);
setElements(starterLayout);
setSelectedElementId(starterLayout[0]?.id ?? null);
setResizingElementId(null);
return;
}
const loadedElements = normalizeTemplateElements(response.elements);
if (!loadedElements) {
throw new Error("Gespeichertes Template konnte nicht geladen werden.");
}
setElements(loadedElements);
setSelectedElementId(loadedElements[0]?.id ?? null);
setResizingElementId(null);
})
.catch((error) => {
if (!cancelled) {
if (error instanceof ApiError && error.status === 404) {
const starterLayout = createInvoiceStarterLayout(user, INVOICE_PALETTE_ITEMS);
setElements(starterLayout);
setSelectedElementId(starterLayout[0]?.id ?? null);
setResizingElementId(null);
setTemplateUpdatedAt(null);
setTemplateError(null);
return;
}
setTemplateError((error as Error).message);
}
})
.finally(() => {
if (!cancelled) {
setIsTemplateLoading(false);
}
});
return () => {
cancelled = true;
};
}, [user]);
useEffect(() => {
if (selectedElementId && elements.some((entry) => entry.id === selectedElementId)) {
return;
}
setSelectedElementId(elements[0]?.id ?? null);
}, [elements, selectedElementId]);
useEffect(() => {
return () => {
mouseDragCleanupRef.current?.();
};
}, []);
useEffect(() => {
return () => {
if (pdfPreviewUrl) {
URL.revokeObjectURL(pdfPreviewUrl);
}
};
}, [pdfPreviewUrl]);
useEffect(() => {
if (!pdfPreviewUrl) {
return;
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
setPdfPreviewUrl(null);
}
}
window.addEventListener("keydown", handleEscape);
return () => {
window.removeEventListener("keydown", handleEscape);
};
}, [pdfPreviewUrl]);
useEffect(() => {
let frameId = 0;
function updateCanvasViewport() {
cancelAnimationFrame(frameId);
frameId = window.requestAnimationFrame(() => {
const pageElement = pageRef.current;
const cardElement = cardRef.current;
const canvasShellElement = canvasShellRef.current;
if (!pageElement || !cardElement || !canvasShellElement) {
return;
}
const pageRect = pageElement.getBoundingClientRect();
const nextPageHeight = Math.max(window.innerHeight - pageRect.top - 36, 420);
setPageViewportHeight((current) => (current === nextPageHeight ? current : nextPageHeight));
const cardStyles = window.getComputedStyle(cardElement);
const shellStyles = window.getComputedStyle(canvasShellElement);
const syncPanelHeight = window.innerWidth > 1200;
const cardPaddingBottom = Number.parseFloat(cardStyles.paddingBottom) || 0;
const horizontalPadding =
(Number.parseFloat(shellStyles.paddingLeft) || 0) +
(Number.parseFloat(shellStyles.paddingRight) || 0);
const verticalPadding =
(Number.parseFloat(shellStyles.paddingTop) || 0) +
(Number.parseFloat(shellStyles.paddingBottom) || 0);
const cardRect = cardElement.getBoundingClientRect();
const shellRect = canvasShellElement.getBoundingClientRect();
const availableWidth = Math.max(
Math.floor(canvasShellElement.clientWidth - horizontalPadding),
320,
);
const availableHeight = Math.max(
Math.floor(cardRect.bottom - shellRect.top - cardPaddingBottom - verticalPadding - CANVAS_BOTTOM_GAP),
240,
);
const nextWidth = Math.min(
CANVAS_WIDTH,
availableWidth,
Math.floor(availableHeight * CANVAS_ASPECT_RATIO),
);
const nextHeight = Math.floor(nextWidth / CANVAS_ASPECT_RATIO);
const nextScale = nextWidth / CANVAS_WIDTH;
setCanvasViewport((current) => {
if (
current.height === nextHeight &&
current.scale === nextScale &&
current.syncPanelHeight === syncPanelHeight &&
current.width === nextWidth
) {
return current;
}
return {
height: nextHeight,
scale: nextScale,
syncPanelHeight,
width: nextWidth,
};
});
});
}
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(() => {
updateCanvasViewport();
});
if (resizeObserver) {
if (pageRef.current) {
resizeObserver.observe(pageRef.current);
}
if (cardRef.current) {
resizeObserver.observe(cardRef.current);
}
if (canvasShellRef.current) {
resizeObserver.observe(canvasShellRef.current);
}
}
updateCanvasViewport();
window.addEventListener("resize", updateCanvasViewport);
window.addEventListener("scroll", updateCanvasViewport, { passive: true });
return () => {
cancelAnimationFrame(frameId);
resizeObserver?.disconnect();
window.removeEventListener("resize", updateCanvasViewport);
window.removeEventListener("scroll", updateCanvasViewport);
};
}, [pdfPreviewUrl]);
function resetCanvasDragState() {
dragDepthRef.current = 0;
setIsCanvasActive(false);
}
function updateElement(elementId: string, patch: Partial<TemplateElement>) {
setElements((current) =>
current.map((entry) =>
entry.id === elementId
? normalizeElement({
...entry,
...(!isElementContentEditable(entry, INVOICE_LOCKED_TEXT_PALETTE_IDS) &&
Object.prototype.hasOwnProperty.call(patch, "content")
? { ...patch, content: entry.content }
: patch),
})
: entry,
),
);
}
function removeElement(elementId: string) {
mouseDragCleanupRef.current?.();
setElements((current) => current.filter((entry) => entry.id !== elementId));
setSelectedElementId((current) => (current === elementId ? null : current));
setResizingElementId((current) => (current === elementId ? null : current));
}
function placePaletteElement(paletteId: string, clientX: number, clientY: number) {
const paletteItem = findPaletteItem(INVOICE_PALETTE_ITEMS, paletteId);
const canvasElement = canvasRef.current;
if (!paletteItem || !canvasElement) {
return;
}
const rect = canvasElement.getBoundingClientRect();
const previewHeight = getDefaultElementHeight(
paletteItem.kind ?? "text",
paletteItem.fontSize,
paletteItem.lineOrientation,
);
const x = (clientX - rect.left) / canvasViewport.scale - paletteItem.width / 2;
const y = (clientY - rect.top) / canvasViewport.scale - previewHeight / 2;
const nextElement = createElementFromPalette(paletteItem, user, { x, y });
setElements((current) => [...current, nextElement]);
setSelectedElementId(nextElement.id);
}
function moveCanvasElement(elementId: string, clientX: number, clientY: number) {
const canvasElement = canvasRef.current;
if (!canvasElement) {
return;
}
const rect = canvasElement.getBoundingClientRect();
const moveOffset = moveOffsetRef.current;
const x = (clientX - rect.left) / canvasViewport.scale - (moveOffset?.x ?? 0);
const y = (clientY - rect.top) / canvasViewport.scale - (moveOffset?.y ?? 0);
updateElement(elementId, { x, y });
}
function handleCanvasDragEnter(event: DragEvent<HTMLDivElement>) {
event.preventDefault();
dragDepthRef.current += 1;
setIsCanvasActive(true);
}
function handleCanvasDragOver(event: DragEvent<HTMLDivElement>) {
event.preventDefault();
const payload = readDragPayload(event);
event.dataTransfer.dropEffect = payload?.kind === "element" ? "move" : "copy";
setIsCanvasActive(true);
}
function handleCanvasDragLeave(event: DragEvent<HTMLDivElement>) {
event.preventDefault();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) {
setIsCanvasActive(false);
}
}
function handleCanvasDrop(event: DragEvent<HTMLDivElement>) {
event.preventDefault();
const payload = readDragPayload(event);
resetCanvasDragState();
if (!payload) {
return;
}
if (payload.kind === "palette") {
placePaletteElement(payload.paletteId, event.clientX, event.clientY);
return;
}
moveCanvasElement(payload.elementId, event.clientX, event.clientY);
}
function handleResetToStarter() {
mouseDragCleanupRef.current?.();
const starterLayout = createInvoiceStarterLayout(user, INVOICE_PALETTE_ITEMS);
setElements(starterLayout);
setSelectedElementId(starterLayout[0]?.id ?? null);
setResizingElementId(null);
}
function handleClearCanvas() {
if (!window.confirm("Moechten Sie wirklich alle Elemente vom Canvas entfernen?")) {
return;
}
mouseDragCleanupRef.current?.();
setElements([]);
setSelectedElementId(null);
setResizingElementId(null);
}
function handleCreatePdfPreview() {
try {
const pdfBlob = createPdfBlob(elements);
const nextPreviewUrl = URL.createObjectURL(pdfBlob);
setPdfPreviewUrl(nextPreviewUrl);
setPdfError(null);
} catch {
setPdfError("PDF konnte nicht erstellt werden.");
}
}
async function handleSaveTemplate() {
if (!user) {
setTemplateError("Vorlagen koennen nur fuer angemeldete Benutzer gespeichert werden.");
return;
}
if (!isTemplateApiAvailable) {
setTemplateError("Template-Speicherung ist auf diesem Server nicht verfuegbar.");
return;
}
setTemplateError(null);
setIsTemplateSaving(true);
try {
const response = await apiPut<InvoiceTemplateResponse>("/session/invoice-template", {
elements,
});
const savedElements = normalizeTemplateElements(response.elements);
if (!savedElements) {
throw new Error("Gespeichertes Template konnte nicht gelesen werden.");
}
setElements(savedElements);
setSelectedElementId((current) =>
savedElements.some((entry) => entry.id === current) ? current : savedElements[0]?.id ?? null,
);
setResizingElementId(null);
setTemplateUpdatedAt(response.updatedAt);
setIsTemplateApiAvailable(true);
} catch (error) {
if (error instanceof ApiError && error.status === 404) {
setIsTemplateApiAvailable(false);
setTemplateError("Template-Speicherung ist auf diesem Server nicht verfuegbar.");
} else {
setTemplateError((error as Error).message);
}
} finally {
setIsTemplateSaving(false);
}
}
async function handleImageUpload(
event: ReactChangeEvent<HTMLInputElement>,
elementId: string,
) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) {
return;
}
const currentElement = elements.find((entry) => entry.id === elementId);
if (!currentElement || currentElement.kind !== "image") {
return;
}
try {
const image = await convertImageFileToJpeg(file);
const nextHeight = Math.max(
MIN_MEDIA_HEIGHT,
Math.round((currentElement.width * image.height) / image.width),
);
updateElement(elementId, {
height: nextHeight,
imageNaturalHeight: image.height,
imageNaturalWidth: image.width,
imageSrc: image.dataUrl,
});
setPdfError(null);
} catch (error) {
setPdfError((error as Error).message);
}
}
function handleElementMouseDown(
event: ReactMouseEvent<HTMLButtonElement>,
elementId: string,
) {
const canvasElement = canvasRef.current;
if (!canvasElement || event.button !== 0) {
return;
}
mouseDragCleanupRef.current?.();
const rect = event.currentTarget.getBoundingClientRect();
const startOffset = {
x: (event.clientX - rect.left) / canvasViewport.scale,
y: (event.clientY - rect.top) / canvasViewport.scale,
};
moveOffsetRef.current = startOffset;
setSelectedElementId(elementId);
setDraggingElementId(elementId);
setResizingElementId(null);
event.currentTarget.focus();
event.preventDefault();
function handleMouseMove(moveEvent: MouseEvent) {
moveCanvasElement(elementId, moveEvent.clientX, moveEvent.clientY);
}
function stopDragging() {
moveOffsetRef.current = null;
setDraggingElementId(null);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
mouseDragCleanupRef.current = null;
}
function handleMouseUp() {
stopDragging();
}
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
mouseDragCleanupRef.current = stopDragging;
}
function handleResizeMouseDown(
event: ReactMouseEvent<HTMLSpanElement>,
elementId: string,
) {
const currentElement = elements.find((entry) => entry.id === elementId);
if (!currentElement || event.button !== 0) {
return;
}
mouseDragCleanupRef.current?.();
setSelectedElementId(elementId);
setDraggingElementId(null);
setResizingElementId(elementId);
event.currentTarget.parentElement?.focus();
event.preventDefault();
event.stopPropagation();
const startWidth = currentElement.width;
const startHeight = getElementHeight(currentElement);
const elementKind = currentElement.kind;
const elementFontSize = currentElement.fontSize;
const elementLineOrientation = currentElement.lineOrientation;
const imageNaturalWidth = currentElement.imageNaturalWidth;
const imageNaturalHeight = currentElement.imageNaturalHeight;
const startClientX = event.clientX;
const startClientY = event.clientY;
function handleMouseMove(moveEvent: MouseEvent) {
const deltaX = (moveEvent.clientX - startClientX) / canvasViewport.scale;
const deltaY = (moveEvent.clientY - startClientY) / canvasViewport.scale;
const nextWidth = Math.max(
getMinimumWidthForElement(elementKind, elementLineOrientation),
snapToGrid(startWidth + deltaX),
);
const nextHeight = Math.max(
getMinimumHeightForElement(
elementKind,
elementFontSize,
elementLineOrientation,
),
snapToGrid(startHeight + deltaY),
);
if (elementKind === "image") {
const assetWidth = imageNaturalWidth ?? startWidth;
const assetHeight = imageNaturalHeight ?? startHeight;
const widthScale = nextWidth / assetWidth;
const heightScale = nextHeight / assetHeight;
if (widthScale <= heightScale) {
updateElement(elementId, {
height: Math.max(
getMinimumHeightForElement(elementKind, elementFontSize),
Math.floor(((nextWidth * assetHeight) / assetWidth) / GRID_SIZE) * GRID_SIZE,
),
width: nextWidth,
});
return;
}
updateElement(elementId, {
height: nextHeight,
width: Math.max(
getMinimumWidthForElement(elementKind),
Math.floor(((nextHeight * assetWidth) / assetHeight) / GRID_SIZE) * GRID_SIZE,
),
});
return;
}
updateElement(elementId, {
width: nextWidth,
...(elementKind === "text" ? {} : { height: nextHeight }),
});
}
function stopResizing() {
setResizingElementId(null);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
mouseDragCleanupRef.current = null;
}
function handleMouseUp() {
stopResizing();
}
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
mouseDragCleanupRef.current = stopResizing;
}
function handleElementKeyDown(
event: ReactKeyboardEvent<HTMLButtonElement>,
elementId: string,
) {
const currentElement = elements.find((entry) => entry.id === elementId);
if (!currentElement) {
return;
}
if (event.key === "Delete") {
event.preventDefault();
removeElement(elementId);
return;
}
let patch: Partial<TemplateElement> | null = null;
if (event.key === "ArrowLeft") {
patch = { x: currentElement.x - GRID_SIZE };
} else if (event.key === "ArrowRight") {
patch = { x: currentElement.x + GRID_SIZE };
} else if (event.key === "ArrowUp") {
patch = { y: currentElement.y - GRID_SIZE };
} else if (event.key === "ArrowDown") {
patch = { y: currentElement.y + GRID_SIZE };
}
if (!patch) {
return;
}
event.preventDefault();
setSelectedElementId(elementId);
updateElement(elementId, patch);
}
const panelStyle = canvasViewport.syncPanelHeight
? { height: `${canvasViewport.height}px` }
: undefined;
const pageStyle =
pageViewportHeight && canvasViewport.syncPanelHeight
? { height: `${pageViewportHeight}px` }
: undefined;
return (
<div ref={pageRef} className="page-stack invoice-template-page" style={pageStyle}>
<section ref={cardRef} className="section-card invoice-template-page__card">
<div className="section-card__header">
<div>
<h3>Rechnungsvorlage bearbeiten</h3>
</div>
<div className="page-actions">
<button type="button" className="accent-button" onClick={handleCreatePdfPreview}>
PDF erstellen
</button>
<button
type="button"
className="secondary-button"
onClick={() => {
void handleSaveTemplate();
}}
disabled={isTemplateLoading || isTemplateSaving || !isTemplateApiAvailable}
>
{isTemplateSaving ? "Speichert..." : "Template speichern"}
</button>
<button type="button" className="secondary-button" onClick={handleResetToStarter}>
Startlayout laden
</button>
<button type="button" className="ghost-button" onClick={handleClearCanvas}>
Canvas leeren
</button>
</div>
</div>
{pdfError ? <div className="alert alert--error">{pdfError}</div> : null}
{templateError ? <div className="alert alert--error">{templateError}</div> : null}
<div className="invoice-template">
<aside className="invoice-template__panel" style={panelStyle}>
<div className="invoice-template__panel-header">
<div>
<h4>Elemente</h4>
</div>
</div>
<div className="invoice-template__palette">
{INVOICE_PALETTE_GROUPS.map((group) => (
<section key={group.category} className="invoice-template__palette-group">
<div className="invoice-template__palette-group-header">
<h5>{group.title}</h5>
</div>
<div className="invoice-template__palette-grid">
{INVOICE_PALETTE_ITEMS.filter((item) => item.category === group.category).map((item) => (
<button
key={item.id}
type="button"
draggable
className="invoice-template__tile"
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(
DRAG_DATA_TYPE,
JSON.stringify(
{ kind: "palette", paletteId: item.id } satisfies DragPayload,
),
);
}}
onDragEnd={resetCanvasDragState}
>
<span>{getPalettePreviewText(item, user)}</span>
</button>
))}
</div>
</section>
))}
</div>
</aside>
<div className="invoice-template__canvas-column">
<div className="invoice-template__canvas-header">
<div>
<h4>Rechnungsvorlage</h4>
<span className="invoice-template__panel-note">
{isTemplateLoading
? "Gespeicherte Vorlage wird geladen..."
: !isTemplateApiAvailable
? "Template-API auf diesem Server nicht verfuegbar"
: templateTimestampLabel
? `In Datenbank gespeichert: ${templateTimestampLabel}`
: "Noch keine gespeicherte Vorlage vorhanden"}
</span>
</div>
</div>
<div ref={canvasShellRef} className="invoice-template__canvas-shell">
<div
className="invoice-template__canvas-stage"
style={{
height: `${canvasViewport.height}px`,
width: `${canvasViewport.width}px`,
}}
>
<div
ref={canvasRef}
className={`invoice-template__canvas ${isCanvasActive ? "is-active" : ""}`}
style={{ transform: `scale(${canvasViewport.scale})` }}
onDragEnter={handleCanvasDragEnter}
onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop}
>
{elements.length ? (
elements.map((element) => (
<button
key={element.id}
type="button"
className={`invoice-template__canvas-element invoice-template__canvas-element--${element.kind} ${selectedElementId === element.id ? "is-selected" : ""} ${draggingElementId === element.id ? "is-dragging" : ""} ${resizingElementId === element.id ? "is-resizing" : ""}`}
style={{
left: `${element.x}px`,
top: `${element.y}px`,
width: `${element.width}px`,
...(element.kind === "text"
? {
fontSize: `${element.fontSize}px`,
fontWeight: element.fontWeight,
textAlign: element.textAlign,
}
: { height: `${getElementHeight(element)}px` }),
}}
onMouseDown={(event) => handleElementMouseDown(event, element.id)}
onKeyDown={(event) => handleElementKeyDown(event, element.id)}
onClick={() => setSelectedElementId(element.id)}
onFocus={() => setSelectedElementId(element.id)}
>
{element.kind === "text" ? (
<span className="invoice-template__canvas-element-text">
{element.content || "Text eingeben"}
</span>
) : null}
{element.kind === "line" ? (
<span
aria-hidden="true"
className={`invoice-template__canvas-line invoice-template__canvas-line--${element.lineOrientation ?? "horizontal"}`}
/>
) : null}
{element.kind === "image" ? (
element.imageSrc ? (
<img
alt=""
className="invoice-template__canvas-image"
src={element.imageSrc}
/>
) : (
<span className="invoice-template__canvas-image-placeholder">
Bild auswaehlen
</span>
)
) : null}
{selectedElementId === element.id ? (
<span
aria-hidden="true"
className="invoice-template__canvas-element-resize-handle"
onMouseDown={(event) => handleResizeMouseDown(event, element.id)}
/>
) : null}
</button>
))
) : (
<div className="invoice-template__canvas-empty">{emptyCanvasMessage}</div>
)}
</div>
</div>
</div>
</div>
<aside className="invoice-template__panel" style={panelStyle}>
<div className="invoice-template__panel-header">
<div>
<h4>Einstellungen</h4>
</div>
<span className="invoice-template__panel-note">
{selectedElement ? "" : "Noch nichts ausgewählt"}
</span>
</div>
{selectedElement ? (
<div className="invoice-template__inspector">
<label className="field">
<span>Elementname</span>
<input
value={selectedElement.label}
onChange={(event) =>
updateElement(selectedElement.id, { label: event.target.value })
}
/>
</label>
{selectedElement.kind === "text" ? (
<label className="field">
<span>Inhalt</span>
<textarea
value={selectedElement.content}
readOnly={!selectedElementContentEditable}
onChange={
selectedElementContentEditable
? (event) =>
updateElement(selectedElement.id, {
content: event.target.value,
})
: undefined
}
/>
{!selectedElementContentEditable ? (
<small>Dieser Inhalt ist fuer dieses Element gesperrt.</small>
) : null}
</label>
) : null}
{selectedElement.kind === "image" ? (
<div className="field">
<span>Bilddatei</span>
<input
type="file"
accept="image/*"
onChange={(event) => {
void handleImageUpload(event, selectedElement.id);
}}
/>
<small>
{selectedElement.imageSrc
? "Bild geladen. Ein neuer Upload ersetzt die aktuelle Datei."
: "Noch kein Bild hinterlegt."}
</small>
</div>
) : null}
<div className="field-grid">
<label className="field">
<span>X-Position</span>
<input
type="number"
value={selectedElement.x}
min={0}
max={CANVAS_WIDTH}
step={GRID_SIZE}
onChange={(event) =>
updateElement(selectedElement.id, { x: Number(event.target.value) })
}
/>
</label>
<label className="field">
<span>Y-Position</span>
<input
type="number"
value={selectedElement.y}
min={0}
max={CANVAS_HEIGHT}
step={GRID_SIZE}
onChange={(event) =>
updateElement(selectedElement.id, { y: Number(event.target.value) })
}
/>
</label>
<label className="field">
<span>Breite</span>
<input
type="number"
value={selectedElement.width}
min={selectedElementMinimumWidth}
max={CANVAS_WIDTH}
onChange={(event) =>
updateElement(selectedElement.id, { width: Number(event.target.value) })
}
/>
</label>
{selectedElement.kind === "text" ? (
<label className="field">
<span>Schriftgroesse</span>
<input
type="number"
value={selectedElement.fontSize}
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
onChange={(event) =>
updateElement(selectedElement.id, { fontSize: Number(event.target.value) })
}
/>
</label>
) : (
<label className="field">
<span>Hoehe</span>
<input
type="number"
value={selectedElementHeight ?? 0}
min={selectedElementMinimumHeight}
max={CANVAS_HEIGHT}
onChange={(event) =>
updateElement(selectedElement.id, { height: Number(event.target.value) })
}
/>
</label>
)}
</div>
{selectedElement.kind === "text" ? (
<>
<label className="field">
<span>Schriftschnitt</span>
<select
value={selectedElement.fontWeight}
onChange={(event) =>
updateElement(selectedElement.id, {
fontWeight: Number(event.target.value) as FontWeight,
})
}
>
<option value="400">Normal</option>
<option value="500">Mittel</option>
<option value="600">Halbfett</option>
<option value="700">Fett</option>
</select>
</label>
<div className="field">
<span>Ausrichtung</span>
<div className="choice-row">
{(["left", "center", "right"] as const).map((align) => (
<button
key={align}
type="button"
className={`choice-chip ${selectedElement.textAlign === align ? "is-selected" : ""}`}
onClick={() => updateElement(selectedElement.id, { textAlign: align })}
>
{align === "left" ? "Links" : align === "center" ? "Zentriert" : "Rechts"}
</button>
))}
</div>
</div>
</>
) : null}
{selectedElement.kind === "line" ? (
<label className="field">
<span>Ausrichtung</span>
<select
value={selectedElement.lineOrientation ?? "horizontal"}
onChange={(event) =>
updateElement(selectedElement.id, {
height: selectedElement.width,
lineOrientation: event.target.value as LineOrientation,
width: selectedElementHeight ?? selectedElement.width,
})
}
>
<option value="horizontal">Horizontal</option>
<option value="vertical">Vertikal</option>
</select>
</label>
) : null}
<div className="invoice-template__inspector-actions">
{selectedElement.kind === "image" && selectedElement.imageSrc ? (
<button
type="button"
className="secondary-button"
onClick={() =>
updateElement(selectedElement.id, {
imageNaturalHeight: null,
imageNaturalWidth: null,
imageSrc: null,
})
}
>
Bild entfernen
</button>
) : null}
<button
type="button"
className="ghost-button"
onClick={() => removeElement(selectedElement.id)}
>
Element entfernen
</button>
</div>
</div>
) : (
<div className="empty-state">
Klicken Sie ein Element auf dem Canvas an, damit hier seine Einstellungen erscheinen.
</div>
)}
</aside>
</div>
</section>
{pdfPreviewUrl ? (
<div className="dialog-backdrop" onClick={() => setPdfPreviewUrl(null)}>
<div className="dialog dialog--wide" onClick={(event) => event.stopPropagation()}>
<div className="dialog__header">
<div>
<h4>PDF-Vorschau</h4>
</div>
<div className="dialog__actions">
<a
href={pdfPreviewUrl}
download="rechnung-template.pdf"
className="secondary-button"
>
PDF herunterladen
</a>
<button
type="button"
className="ghost-button"
onClick={() => setPdfPreviewUrl(null)}
>
Schließen
</button>
</div>
</div>
<div className="dialog__body dialog__body--pdf">
<iframe
className="dialog__frame"
src={pdfPreviewUrl}
title="PDF-Vorschau Rechnungsvorlage"
/>
</div>
</div>
</div>
) : null}
</div>
);
}