Remove all invoice totals elements: - invoice-subtotal (Zwischensumme) - invoice-tax (Mehrwertsteuer) - invoice-total (Gesamtbetrag) Remove 'Beträge' category from palette groups. Adjust positions of remaining elements (payment-terms, bank-details).
2195 lines
70 KiB
TypeScript
2195 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",
|
|
|
|
|
|
"payment-terms",
|
|
"bank-details",
|
|
"issuer-name",
|
|
"issuer-street",
|
|
"issuer-house-number",
|
|
"issuer-postal-code",
|
|
"issuer-city",
|
|
"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-street",
|
|
category: "issuer-data",
|
|
label: "Aussteller-Straße",
|
|
description: "Straße des Rechnungsausstellers",
|
|
width: 200,
|
|
fontSize: 14,
|
|
fontWeight: 400,
|
|
textAlign: "left",
|
|
defaultContent: (user) => user?.street ?? "",
|
|
},
|
|
{
|
|
id: "issuer-house-number",
|
|
category: "issuer-data",
|
|
label: "Aussteller-Hausnummer",
|
|
description: "Hausnummer des Rechnungsausstellers",
|
|
width: 80,
|
|
fontSize: 14,
|
|
fontWeight: 400,
|
|
textAlign: "left",
|
|
defaultContent: (user) => user?.houseNumber ?? "",
|
|
},
|
|
{
|
|
id: "issuer-postal-code",
|
|
category: "issuer-data",
|
|
label: "Aussteller-PLZ",
|
|
description: "Postleitzahl des Rechnungsausstellers",
|
|
width: 80,
|
|
fontSize: 14,
|
|
fontWeight: 400,
|
|
textAlign: "left",
|
|
defaultContent: (user) => user?.postalCode ?? "",
|
|
},
|
|
{
|
|
id: "issuer-city",
|
|
category: "issuer-data",
|
|
label: "Aussteller-Ort",
|
|
description: "Ort des Rechnungsausstellers",
|
|
width: 200,
|
|
fontSize: 14,
|
|
fontWeight: 400,
|
|
textAlign: "left",
|
|
defaultContent: (user) => 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: "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-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-street"), user, { x: 56, y: 86 }),
|
|
createElementFromPalette(requirePaletteItem(paletteItems, "issuer-house-number"), user, { x: 260, y: 86 }),
|
|
createElementFromPalette(requirePaletteItem(paletteItems, "issuer-postal-code"), user, { x: 56, y: 110 }),
|
|
createElementFromPalette(requirePaletteItem(paletteItems, "issuer-city"), user, { x: 140, y: 110 }),
|
|
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),
|
|
horizontalLine(56, 560, 646),
|
|
createElementFromPalette(requirePaletteItem(paletteItems, "payment-terms"), user, { x: 56, y: 360 }),
|
|
createElementFromPalette(requirePaletteItem(paletteItems, "bank-details"), user, { x: 56, y: 400 }),
|
|
];
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|