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 = { 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, ) { 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((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, ) { 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, lockedTextPaletteIds: ReadonlySet, ) { 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((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((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((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; 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): 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(null); const cardRef = useRef(null); const canvasRef = useRef(null); const canvasShellRef = useRef(null); const dragDepthRef = useRef(0); const mouseDragCleanupRef = useRef<(() => void) | null>(null); const moveOffsetRef = useRef<{ x: number; y: number } | null>(null); const [elements, setElements] = useState(() => createInvoiceStarterLayout(user, INVOICE_PALETTE_ITEMS), ); const [selectedElementId, setSelectedElementId] = useState(null); const [isCanvasActive, setIsCanvasActive] = useState(false); const [draggingElementId, setDraggingElementId] = useState(null); const [resizingElementId, setResizingElementId] = useState(null); const [pdfError, setPdfError] = useState(null); const [templateError, setTemplateError] = useState(null); const [templateUpdatedAt, setTemplateUpdatedAt] = useState(null); const [isTemplateLoading, setIsTemplateLoading] = useState(false); const [isTemplateSaving, setIsTemplateSaving] = useState(false); const [isTemplateApiAvailable, setIsTemplateApiAvailable] = useState(true); const [pdfPreviewUrl, setPdfPreviewUrl] = useState(null); const [pageViewportHeight, setPageViewportHeight] = useState(null); const [canvasViewport, setCanvasViewport] = useState({ 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("/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) { 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) { event.preventDefault(); dragDepthRef.current += 1; setIsCanvasActive(true); } function handleCanvasDragOver(event: DragEvent) { event.preventDefault(); const payload = readDragPayload(event); event.dataTransfer.dropEffect = payload?.kind === "element" ? "move" : "copy"; setIsCanvasActive(true); } function handleCanvasDragLeave(event: DragEvent) { event.preventDefault(); dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) { setIsCanvasActive(false); } } function handleCanvasDrop(event: DragEvent) { 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("/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, 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, 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, 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, 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 | 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 (

Rechnungsvorlage bearbeiten

{pdfError ?
{pdfError}
: null} {templateError ?
{templateError}
: null}

Rechnungsvorlage

{isTemplateLoading ? "Gespeicherte Vorlage wird geladen..." : !isTemplateApiAvailable ? "Template-API auf diesem Server nicht verfuegbar" : templateTimestampLabel ? `In Datenbank gespeichert: ${templateTimestampLabel}` : "Noch keine gespeicherte Vorlage vorhanden"}
{elements.length ? ( elements.map((element) => ( )) ) : (
{emptyCanvasMessage}
)}