diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a4fac72..d00869a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import SearchPage from "./pages/SearchPage"; import SearchFarmerPage from "./pages/SearchFarmerPage"; import SearchCalendarPage from "./pages/SearchCalendarPage"; import UserManagementPage from "./pages/UserManagementPage"; +import InvoiceTemplatePage from "./pages/InvoiceTemplatePage"; function ProtectedRoutes() { const { user, ready } = useSession(); @@ -35,6 +36,7 @@ function ProtectedRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/layout/AppShell.tsx b/frontend/src/layout/AppShell.tsx index b2fcd85..2deef3d 100644 --- a/frontend/src/layout/AppShell.tsx +++ b/frontend/src/layout/AppShell.tsx @@ -5,6 +5,7 @@ const PAGE_TITLES: Record = { "/home": "Startseite", "/samples/new": "Neuanlage einer Probe", "/portal": "MUH-Portal", + "/invoice-template": "Rechnungstemplate", }; function resolvePageTitle(pathname: string) { @@ -79,6 +80,9 @@ export default function AppShell() {
Verwaltung
+ `nav-sublink ${isActive ? "is-active" : ""}`}> + Rechnungstemplate + `nav-sublink ${isActive ? "is-active" : ""}`}> Landwirte diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 1b0036b..3557239 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -1,2 +1,3 @@ export const USER_STORAGE_KEY = "muh.current-user"; export const AUTH_TOKEN_STORAGE_KEY = "muh.auth-token"; +export const INVOICE_TEMPLATE_STORAGE_KEY = "muh.invoice-template"; diff --git a/frontend/src/pages/InvoiceTemplatePage.tsx b/frontend/src/pages/InvoiceTemplatePage.tsx new file mode 100644 index 0000000..dfcdb5b --- /dev/null +++ b/frontend/src/pages/InvoiceTemplatePage.tsx @@ -0,0 +1,1252 @@ +import { + useEffect, + useRef, + useState, + type DragEvent, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { useSession } from "../lib/session"; +import { INVOICE_TEMPLATE_STORAGE_KEY } from "../lib/storage"; +import type { UserOption } from "../lib/types"; + +const CANVAS_WIDTH = 794; +const CANVAS_HEIGHT = 1123; +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 = 8; + +type TextAlign = "left" | "center" | "right"; +type FontWeight = 400 | 500 | 600 | 700; + +interface PaletteItem { + id: string; + label: string; + description: string; + width: number; + fontSize: number; + fontWeight: FontWeight; + textAlign: TextAlign; + defaultContent: (user: UserOption | null) => string; +} + +interface TemplateElement { + id: string; + paletteId: string; + label: string; + content: string; + x: number; + y: number; + width: number; + fontSize: number; + fontWeight: FontWeight; + textAlign: TextAlign; +} + +interface CanvasViewport { + height: number; + scale: number; + syncPanelHeight: boolean; + width: number; +} + +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 PALETTE_ITEMS: PaletteItem[] = [ + { + id: "invoice-title", + label: "Rechnungstitel", + description: "Markante Ueberschrift fuer die Rechnung", + width: 240, + fontSize: 32, + fontWeight: 700, + textAlign: "left", + defaultContent: () => "Rechnung", + }, + { + id: "company-name", + label: "Firmenname", + description: "Name Ihres Unternehmens", + width: 280, + fontSize: 24, + fontWeight: 700, + textAlign: "left", + defaultContent: (user) => user?.companyName || "Firmenname", + }, + { + id: "contact-person", + label: "Ansprechpartner", + description: "Name des Ansprechpartners", + width: 240, + fontSize: 16, + fontWeight: 500, + textAlign: "left", + defaultContent: (user) => user?.displayName || "Ansprechpartner", + }, + { + id: "street", + label: "Strasse", + description: "Strassenname fuer die Anschrift", + width: 220, + fontSize: 16, + fontWeight: 400, + textAlign: "left", + defaultContent: (user) => user?.street || "Musterstrasse", + }, + { + id: "house-number", + label: "Hausnummer", + description: "Hausnummer der Anschrift", + width: 100, + fontSize: 16, + fontWeight: 400, + textAlign: "left", + defaultContent: (user) => user?.houseNumber || "12", + }, + { + id: "postal-code", + label: "PLZ", + description: "Postleitzahl", + width: 100, + fontSize: 16, + fontWeight: 400, + textAlign: "left", + defaultContent: (user) => user?.postalCode || "12345", + }, + { + id: "city", + label: "Ort", + description: "Stadt oder Ort", + width: 180, + fontSize: 16, + fontWeight: 400, + textAlign: "left", + defaultContent: (user) => user?.city || "Musterstadt", + }, + { + id: "email", + label: "E-Mail", + description: "Kontakt-E-Mail", + width: 260, + fontSize: 15, + fontWeight: 400, + textAlign: "left", + defaultContent: (user) => user?.email || "mail@example.de", + }, + { + id: "phone", + label: "Telefon", + description: "Telefonnummer fuer Rueckfragen", + width: 220, + fontSize: 15, + fontWeight: 400, + textAlign: "left", + defaultContent: (user) => user?.phoneNumber || "+49 123 456789", + }, + { + id: "invoice-number", + label: "Rechnungsnummer", + description: "Eindeutige Kennung", + width: 200, + fontSize: 16, + fontWeight: 600, + textAlign: "right", + defaultContent: () => "RE-2026-001", + }, + { + id: "invoice-date", + label: "Rechnungsdatum", + description: "Ausstellungsdatum der Rechnung", + width: 200, + fontSize: 16, + fontWeight: 400, + textAlign: "right", + defaultContent: () => + new Intl.DateTimeFormat("de-DE", { + dateStyle: "medium", + }).format(new Date()), + }, + { + id: "due-date", + label: "Zahlungsziel", + description: "Faelligkeitsdatum", + width: 220, + fontSize: 16, + fontWeight: 400, + textAlign: "right", + defaultContent: () => { + const dueDate = new Date(); + dueDate.setDate(dueDate.getDate() + 14); + return `Faellig bis ${new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(dueDate)}`; + }, + }, + { + id: "customer-number", + label: "Kundennummer", + description: "Referenz zum Kunden", + width: 180, + fontSize: 16, + fontWeight: 500, + textAlign: "left", + defaultContent: () => "KD-1001", + }, + { + id: "line-item", + label: "Leistungsposition", + description: "Beschreibung einer Position", + width: 340, + fontSize: 16, + fontWeight: 400, + textAlign: "left", + defaultContent: () => "Beratung und Diagnostik", + }, + { + id: "amount", + label: "Betrag", + description: "Gesamt- oder Positionsbetrag", + width: 180, + fontSize: 18, + fontWeight: 700, + textAlign: "right", + defaultContent: () => "0,00 EUR", + }, + { + id: "iban", + label: "IBAN", + description: "Bankverbindung fuer die Zahlung", + width: 320, + fontSize: 15, + fontWeight: 400, + textAlign: "left", + defaultContent: () => "DE12 3456 7890 1234 5678 90", + }, + { + id: "bic", + label: "BIC", + description: "BIC der Bank", + width: 180, + fontSize: 15, + fontWeight: 400, + textAlign: "left", + defaultContent: () => "GENODEF1XXX", + }, + { + id: "footer", + label: "Fusszeile", + description: "Hinweis oder Dankeszeile", + width: 360, + fontSize: 14, + fontWeight: 400, + textAlign: "left", + defaultContent: () => "Vielen Dank fuer Ihren Auftrag.", + }, +]; + +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 createPdfContentStream(elements: TemplateElement[]) { + const commands = ["0 g"]; + + for (const element of elements) { + 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 contentStream = createPdfContentStream(elements); + 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 >> >> /Contents 6 0 R >>`, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>", + `<< /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 normalizeElement(element: TemplateElement): TemplateElement { + const width = clamp(Math.round(element.width), 100, CANVAS_WIDTH - 32); + const fontSize = clamp(Math.round(element.fontSize), 12, 44); + const x = clamp(snapToGrid(element.x), 0, CANVAS_WIDTH - width); + const y = clamp(snapToGrid(element.y), 0, CANVAS_HEIGHT - 40); + + return { + ...element, + width, + fontSize, + x, + y, + fontWeight: normalizeFontWeight(element.fontWeight), + textAlign: normalizeTextAlign(element.textAlign), + }; +} + +function findPaletteItem(paletteId: string) { + return PALETTE_ITEMS.find((entry) => entry.id === paletteId) ?? null; +} + +function requirePaletteItem(paletteId: string) { + const paletteItem = findPaletteItem(paletteId); + if (!paletteItem) { + throw new Error(`Palette item ${paletteId} is not configured.`); + } + return paletteItem; +} + +function createElementFromPalette( + paletteItem: PaletteItem, + user: UserOption | null, + position: { x: number; y: number }, +): TemplateElement { + return normalizeElement({ + id: generateId(), + paletteId: paletteItem.id, + label: paletteItem.label, + content: paletteItem.defaultContent(user), + x: position.x, + y: position.y, + width: paletteItem.width, + fontSize: paletteItem.fontSize, + fontWeight: paletteItem.fontWeight, + textAlign: paletteItem.textAlign, + }); +} + +function createStarterLayout(user: UserOption | null) { + return [ + createElementFromPalette(requirePaletteItem("company-name"), user, { x: 56, y: 56 }), + createElementFromPalette(requirePaletteItem("street"), user, { x: 56, y: 104 }), + createElementFromPalette(requirePaletteItem("house-number"), user, { x: 288, y: 104 }), + createElementFromPalette(requirePaletteItem("postal-code"), user, { x: 56, y: 132 }), + createElementFromPalette(requirePaletteItem("city"), user, { x: 164, y: 132 }), + createElementFromPalette(requirePaletteItem("invoice-title"), user, { x: 56, y: 238 }), + createElementFromPalette(requirePaletteItem("invoice-date"), user, { x: 538, y: 64 }), + createElementFromPalette(requirePaletteItem("invoice-number"), user, { x: 538, y: 96 }), + createElementFromPalette(requirePaletteItem("amount"), user, { x: 558, y: 432 }), + createElementFromPalette(requirePaletteItem("footer"), user, { x: 56, y: 1008 }), + ]; +} + +function loadStoredElements(storageKey: string) { + const raw = window.localStorage.getItem(storageKey); + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return null; + } + + const elements = parsed + .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, + label: candidate.label, + content: candidate.content, + x: candidate.x, + y: candidate.y, + width: candidate.width, + fontSize: candidate.fontSize, + fontWeight: normalizeFontWeight(candidate.fontWeight), + textAlign: normalizeTextAlign(candidate.textAlign), + }); + }) + .filter((entry): entry is TemplateElement => entry !== null); + + return elements; + } catch { + return null; + } +} + +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 storageKey = `${INVOICE_TEMPLATE_STORAGE_KEY}.${user?.id ?? "default"}`; + const initialElementsRef = useRef(null); + + if (initialElementsRef.current === null) { + initialElementsRef.current = loadStoredElements(storageKey) ?? createStarterLayout(user); + } + + const initialElements = initialElementsRef.current ?? []; + 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(initialElements); + const [selectedElementId, setSelectedElementId] = useState( + initialElements[0]?.id ?? null, + ); + const [isCanvasActive, setIsCanvasActive] = useState(false); + const [draggingElementId, setDraggingElementId] = useState(null); + const [pdfError, setPdfError] = useState(null); + 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; + + useEffect(() => { + window.localStorage.setItem(storageKey, JSON.stringify(elements)); + }, [elements, storageKey]); + + 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 rawScale = Math.min(1, availableWidth / CANVAS_WIDTH, availableHeight / CANVAS_HEIGHT); + const nextScale = Math.floor(rawScale * 10000) / 10000; + const nextWidth = Math.floor(CANVAS_WIDTH * nextScale); + const nextHeight = Math.floor(CANVAS_HEIGHT * nextScale); + + 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, + }; + }); + }); + } + + updateCanvasViewport(); + window.addEventListener("resize", updateCanvasViewport); + window.addEventListener("scroll", updateCanvasViewport, { passive: true }); + + return () => { + cancelAnimationFrame(frameId); + 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, ...patch }) : entry, + ), + ); + } + + function removeElement(elementId: string) { + setElements((current) => current.filter((entry) => entry.id !== elementId)); + setSelectedElementId((current) => (current === elementId ? null : current)); + } + + function placePaletteElement(paletteId: string, clientX: number, clientY: number) { + const paletteItem = findPaletteItem(paletteId); + const canvasElement = canvasRef.current; + if (!paletteItem || !canvasElement) { + return; + } + + const rect = canvasElement.getBoundingClientRect(); + const x = (clientX - rect.left) / canvasViewport.scale - paletteItem.width / 2; + const y = (clientY - rect.top) / canvasViewport.scale - paletteItem.fontSize; + 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() { + const starterLayout = createStarterLayout(user); + setElements(starterLayout); + setSelectedElementId(starterLayout[0]?.id ?? null); + } + + function handleClearCanvas() { + setElements([]); + setSelectedElementId(null); + } + + function handleCreatePdfPreview() { + try { + const pdfBlob = createPdfBlob(elements); + const nextPreviewUrl = URL.createObjectURL(pdfBlob); + setPdfPreviewUrl(nextPreviewUrl); + setPdfError(null); + } catch { + setPdfError("PDF konnte nicht erstellt werden."); + } + } + + function handleElementMouseDown( + event: ReactMouseEvent, + elementId: string, + ) { + const canvasElement = canvasRef.current; + if (!canvasElement || event.button !== 0) { + return; + } + + 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); + 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 handleElementKeyDown( + event: ReactKeyboardEvent, + elementId: string, + ) { + const currentElement = elements.find((entry) => entry.id === elementId); + if (!currentElement) { + 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 ( +
+
+
+
+

Vorlage bearbeiten

+
+ +
+ + + +
+
+ + {pdfError ?
{pdfError}
: null} + +
+ + +
+
+
+

Rechnungsvorlage

+
+
+ +
+
+
+ {elements.length ? ( + elements.map((element) => ( + + )) + ) : ( +
+ Ziehen Sie links ein Element auf diese Fläche, um Ihr Rechnungstemplate zu + starten. +
+ )} +
+
+
+
+ +