diff --git a/src/main/bundles/README.md b/src/main/bundles/README.md
new file mode 100644
index 0000000..c9737d1
--- /dev/null
+++ b/src/main/bundles/README.md
@@ -0,0 +1,32 @@
+This directory is automatically generated by Vaadin and contains the pre-compiled
+frontend files/resources for your project (frontend development bundle).
+
+It should be added to Version Control System and committed, so that other developers
+do not have to compile it again.
+
+Frontend development bundle is automatically updated when needed:
+- an npm/pnpm package is added with @NpmPackage or directly into package.json
+- CSS, JavaScript or TypeScript files are added with @CssImport, @JsModule or @JavaScript
+- Vaadin add-on with front-end customizations is added
+- Custom theme imports/assets added into 'theme.json' file
+- Exported web component is added.
+
+If your project development needs a hot deployment of the frontend changes,
+you can switch Flow to use Vite development server (default in Vaadin 23.3 and earlier versions):
+- set `vaadin.frontend.hotdeploy=true` in `application.properties`
+- configure `vaadin-maven-plugin`:
+```
+
+ true
+
+```
+- configure `jetty-maven-plugin`:
+```
+
+
+ true
+
+
+```
+
+Read more [about Vaadin development mode](https://vaadin.com/docs/next/flow/configuration/development-mode#precompiled-bundle).
\ No newline at end of file
diff --git a/src/main/bundles/dev.bundle b/src/main/bundles/dev.bundle
new file mode 100644
index 0000000..60bc3fe
Binary files /dev/null and b/src/main/bundles/dev.bundle differ
diff --git a/src/main/frontend/styles/pdf-builder.css b/src/main/frontend/styles/pdf-builder.css
new file mode 100644
index 0000000..1478843
--- /dev/null
+++ b/src/main/frontend/styles/pdf-builder.css
@@ -0,0 +1,182 @@
+/* Styles for PDF Builder View */
+
+.pdf-builder-root {
+ display: flex;
+ flex-direction: column;
+ gap: var(--lumo-space-m);
+ width: 100%;
+}
+
+.pdf-builder-workspace {
+ display: flex;
+ gap: var(--lumo-space-l);
+ align-items: flex-start;
+ width: 100%;
+}
+
+.pdf-palette {
+ width: 260px;
+ min-width: 240px;
+ border: 1px solid var(--lumo-contrast-20pct);
+ border-radius: var(--lumo-border-radius-m);
+ background: var(--lumo-base-color);
+ box-shadow: var(--lumo-box-shadow-xs);
+ padding: var(--lumo-space-m);
+}
+
+.pdf-palette .palette-title {
+ font-weight: 600;
+ margin-bottom: var(--lumo-space-s);
+}
+
+.pdf-palette .tool-item {
+ display: flex;
+ align-items: center;
+ gap: var(--lumo-space-m);
+ padding: var(--lumo-space-s);
+ border: 1px dashed var(--lumo-contrast-20pct);
+ border-radius: var(--lumo-border-radius-s);
+ cursor: grab;
+ user-select: none;
+}
+
+.pdf-palette .tool-item + .tool-item { margin-top: var(--lumo-space-s); }
+
+.pdf-palette .tool-item:hover {
+ background: var(--lumo-contrast-5pct);
+}
+
+.pdf-canvas-wrapper {
+ flex: 1 1 auto;
+ display: flex;
+ justify-content: center;
+}
+
+.pdf-canvas {
+ width: 60%;
+ aspect-ratio: 210 / 297; /* A4 */
+ position: relative;
+ background: white;
+ border: 1px solid var(--lumo-contrast-20pct);
+ box-shadow: var(--lumo-box-shadow-m);
+ background-image: linear-gradient(90deg, rgba(0,0,0,0.03) 1px, transparent 1px),
+ linear-gradient(rgba(0,0,0,0.03) 1px, transparent 1px);
+ background-size: 20px 20px;
+}
+
+.canvas-frame {
+ position: absolute;
+ border: 3px solid var(--lumo-contrast-30pct); /* inactive: light gray */
+ border-radius: var(--lumo-border-radius-s);
+ background: rgba(255,255,255,0.9);
+ resize: none;
+ overflow: auto;
+ min-width: 80px;
+ min-height: 60px;
+ cursor: default;
+ user-select: none;
+}
+
+.canvas-frame.active {
+ border: 3px solid var(--lumo-primary-color); /* active: blue */
+ box-shadow: none;
+ resize: both;
+ cursor: default;
+}
+
+.canvas-frame .text-editor {
+ width: 100%;
+ height: 100%;
+ cursor: text;
+ user-select: text;
+ white-space: pre-wrap;
+ word-break: break-word;
+ overflow-wrap: anywhere;
+}
+
+.canvas-frame .text-toolbar {
+ position: absolute;
+ left: 0;
+ top: -40px;
+ display: none;
+ align-items: center;
+ gap: var(--lumo-space-xs);
+ background: var(--lumo-base-color);
+ border: 1px solid var(--lumo-contrast-20pct);
+ border-radius: var(--lumo-border-radius-s);
+ box-shadow: var(--lumo-box-shadow-xs);
+ padding: 2px 4px;
+ cursor: default;
+ user-select: none;
+}
+
+.canvas-frame.active .text-toolbar { display: flex; }
+
+.canvas-frame img { width: 100%; height: 100%; object-fit: contain; display: block; }
+
+.pdf-builder-actions { display: flex; justify-content: flex-end; }
+
+/* Capturing mode: hide frame borders, handles, toolbars for save/export */
+.pdf-canvas.capturing .canvas-frame,
+.pdf-canvas.capturing .canvas-frame.active {
+ border-color: transparent !important; /* hide border but keep layout */
+ box-shadow: none !important;
+ resize: none !important;
+}
+.pdf-canvas.capturing .frame-handle-move,
+.pdf-canvas.capturing .text-toolbar {
+ display: none !important;
+}
+
+/* Image frames: no scrollbars */
+.image-frame {
+ overflow: hidden; /* hide scrollbars */
+ -ms-overflow-style: none; /* IE/Edge */
+ scrollbar-width: none; /* Firefox */
+}
+.image-frame::-webkit-scrollbar { display: none; } /* WebKit */
+
+/* Text frames: no scrolling allowed */
+.text-frame {
+ overflow: hidden; /* disable scrolling */
+ -ms-overflow-style: none; /* IE/Edge */
+ scrollbar-width: none; /* Firefox */
+}
+.text-frame::-webkit-scrollbar { display: none; } /* WebKit */
+
+/* Also disable scrolling inside the text editor */
+.text-frame .text-editor {
+ overflow: hidden; /* disable scrolling */
+ -ms-overflow-style: none; /* IE/Edge */
+ scrollbar-width: none; /* Firefox */
+}
+.text-frame .text-editor::-webkit-scrollbar { display: none; } /* WebKit */
+
+/* Move handle (top-left) visible only when active */
+.frame-handle-move {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 18px;
+ height: 18px;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ line-height: 1;
+ color: var(--lumo-primary-color);
+ background: #fff;
+ border: 1px solid var(--lumo-primary-color);
+ border-radius: 4px;
+ box-shadow: var(--lumo-box-shadow-xs);
+ cursor: grab;
+ z-index: 2;
+ user-select: none;
+ transform: translate(-50%, -50%); /* center the handle on the top-left corner */
+}
+
+.canvas-frame.active .frame-handle-move { display: flex; }
+
+.frame-handle-move:active { cursor: grabbing; }
+
+/* bottom-right visual icon removed; native CSS resize handle remains */
diff --git a/src/main/java/de/assecutor/votianlt/model/PdfTemplate.java b/src/main/java/de/assecutor/votianlt/model/PdfTemplate.java
new file mode 100644
index 0000000..8a66b8a
--- /dev/null
+++ b/src/main/java/de/assecutor/votianlt/model/PdfTemplate.java
@@ -0,0 +1,56 @@
+package de.assecutor.votianlt.model;
+
+import org.springframework.data.annotation.Id;
+import org.springframework.data.mongodb.core.mapping.Document;
+
+import java.time.Instant;
+
+@Document(collection = "pdf_templates")
+public class PdfTemplate {
+ @Id
+ private String id;
+ private String name;
+ private String dataJson; // raw JSON of canvas state
+ private Instant createdAt;
+ private Instant updatedAt;
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getDataJson() {
+ return dataJson;
+ }
+
+ public void setDataJson(String dataJson) {
+ this.dataJson = dataJson;
+ }
+
+ public Instant getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(Instant createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public Instant getUpdatedAt() {
+ return updatedAt;
+ }
+
+ public void setUpdatedAt(Instant updatedAt) {
+ this.updatedAt = updatedAt;
+ }
+}
diff --git a/src/main/java/de/assecutor/votianlt/pages/service/PDFGenerationService.java b/src/main/java/de/assecutor/votianlt/pages/service/PDFGenerationService.java
new file mode 100644
index 0000000..f981ac8
--- /dev/null
+++ b/src/main/java/de/assecutor/votianlt/pages/service/PDFGenerationService.java
@@ -0,0 +1,42 @@
+package de.assecutor.votianlt.pages.service;
+
+import com.lowagie.text.Document;
+import com.lowagie.text.DocumentException;
+import com.lowagie.text.Image;
+import com.lowagie.text.PageSize;
+import com.lowagie.text.pdf.PdfWriter;
+import org.springframework.stereotype.Service;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+@Service
+public class PDFGenerationService {
+
+ public byte[] generatePdfFromImage(byte[] pngBytes) {
+ try {
+ Document document = new Document(PageSize.A4);
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ PdfWriter.getInstance(document, baos);
+ document.open();
+
+ Image image = Image.getInstance(pngBytes);
+ // Scale image to fit A4 with small margins
+ float margin = 20f;
+ float maxW = PageSize.A4.getWidth() - 2 * margin;
+ float maxH = PageSize.A4.getHeight() - 2 * margin;
+ image.scaleToFit(maxW, maxH);
+ // Center the image
+ float x = (PageSize.A4.getWidth() - image.getScaledWidth()) / 2f;
+ float y = (PageSize.A4.getHeight() - image.getScaledHeight()) / 2f;
+ image.setAbsolutePosition(x, y);
+ document.add(image);
+
+ document.close();
+ return baos.toByteArray();
+ } catch (DocumentException | IOException e) {
+ throw new RuntimeException("Fehler bei der PDF-Erzeugung", e);
+ }
+ }
+}
diff --git a/src/main/java/de/assecutor/votianlt/pages/service/PdfTemplateService.java b/src/main/java/de/assecutor/votianlt/pages/service/PdfTemplateService.java
new file mode 100644
index 0000000..af886eb
--- /dev/null
+++ b/src/main/java/de/assecutor/votianlt/pages/service/PdfTemplateService.java
@@ -0,0 +1,32 @@
+package de.assecutor.votianlt.pages.service;
+
+import de.assecutor.votianlt.model.PdfTemplate;
+import de.assecutor.votianlt.repository.PdfTemplateRepository;
+import org.springframework.stereotype.Service;
+
+import java.time.Instant;
+import java.util.Optional;
+
+@Service
+public class PdfTemplateService {
+ private final PdfTemplateRepository repository;
+
+ public PdfTemplateService(PdfTemplateRepository repository) {
+ this.repository = repository;
+ }
+
+ public PdfTemplate saveOrUpdate(String name, String dataJson) {
+ Optional existing = repository.findByName(name);
+ PdfTemplate t = existing.orElseGet(PdfTemplate::new);
+ t.setName(name);
+ t.setDataJson(dataJson);
+ Instant now = Instant.now();
+ if (t.getCreatedAt() == null) t.setCreatedAt(now);
+ t.setUpdatedAt(now);
+ return repository.save(t);
+ }
+
+ public Optional findByName(String name) {
+ return repository.findByName(name);
+ }
+}
diff --git a/src/main/java/de/assecutor/votianlt/pages/view/PDFBuilderView.java b/src/main/java/de/assecutor/votianlt/pages/view/PDFBuilderView.java
new file mode 100644
index 0000000..659d326
--- /dev/null
+++ b/src/main/java/de/assecutor/votianlt/pages/view/PDFBuilderView.java
@@ -0,0 +1,520 @@
+package de.assecutor.votianlt.pages.view;
+
+import com.vaadin.flow.component.UI;
+import com.vaadin.flow.component.button.Button;
+import com.vaadin.flow.component.button.ButtonVariant;
+import com.vaadin.flow.component.dependency.CssImport;
+import com.vaadin.flow.component.dialog.Dialog;
+import com.vaadin.flow.component.html.Anchor;
+import com.vaadin.flow.component.html.Div;
+import com.vaadin.flow.component.html.H3;
+import com.vaadin.flow.component.html.Image;
+import com.vaadin.flow.component.html.Span;
+import com.vaadin.flow.component.icon.Icon;
+import com.vaadin.flow.component.icon.VaadinIcon;
+import com.vaadin.flow.component.notification.Notification;
+import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
+import com.vaadin.flow.component.orderedlayout.VerticalLayout;
+import com.vaadin.flow.component.page.PendingJavaScriptResult;
+import com.vaadin.flow.component.upload.Upload;
+import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
+import com.vaadin.flow.router.PageTitle;
+import com.vaadin.flow.router.Route;
+import com.vaadin.flow.router.Menu;
+import com.vaadin.flow.server.StreamResource;
+import com.vaadin.flow.server.StreamRegistration;
+import com.vaadin.flow.server.VaadinSession;
+
+import de.assecutor.votianlt.pages.base.ui.view.MainLayout;
+import de.assecutor.votianlt.pages.service.PDFGenerationService;
+import de.assecutor.votianlt.pages.service.PdfTemplateService;
+
+import jakarta.annotation.security.RolesAllowed;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.Serial;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Objects;
+import com.vaadin.flow.component.textfield.TextField;
+
+@Route(value = "pdf-builder", layout = MainLayout.class)
+@PageTitle("PDF Builder")
+@Menu(order = 6, icon = "vaadin:file-text", title = "PDF Builder")
+@RolesAllowed("USER")
+@CssImport("./styles/pdf-builder.css")
+public class PDFBuilderView extends Div {
+
+ private final PDFGenerationService pdfService;
+ private final PdfTemplateService templateService;
+ private final PdfCanvas canvas;
+
+ public PDFBuilderView(PDFGenerationService pdfService, PdfTemplateService templateService) {
+ this.pdfService = pdfService;
+ this.templateService = templateService;
+ addClassName("pdf-builder-root");
+ setSizeFull();
+
+ // Workspace (Palette links, Canvas mittig)
+ Div workspace = new Div();
+ workspace.addClassName("pdf-builder-workspace");
+
+ Div palette = createPalette();
+ canvas = new PdfCanvas();
+
+ workspace.add(palette, canvas);
+ add(workspace);
+
+ // Aktionen unten: Speichern
+ HorizontalLayout actions = new HorizontalLayout();
+ actions.addClassName("pdf-builder-actions");
+ actions.setWidthFull();
+ Button save = new Button("Speichern", e -> openSaveDialog());
+ save.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
+ actions.add(save);
+ add(actions);
+ }
+
+ private void openSaveDialog() {
+ Dialog dlg = new Dialog();
+ dlg.setHeaderTitle("Template speichern");
+ TextField name = new TextField("Template-Name");
+ name.setWidthFull();
+ name.setRequired(true);
+ VerticalLayout content = new VerticalLayout(name);
+ content.setPadding(false);
+ content.setSpacing(true);
+ dlg.add(content);
+ Button cancel = new Button("Abbrechen", e -> dlg.close());
+ Button save = new Button("Speichern & PDF", e -> {
+ if (name.isEmpty()) {
+ Notification.show("Bitte einen Namen angeben", 3000, Notification.Position.MIDDLE);
+ return;
+ }
+ collectAndSave(name.getValue());
+ dlg.close();
+ });
+ save.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
+ dlg.getFooter().add(cancel, save);
+ dlg.open();
+ }
+
+ private void collectAndSave(String templateName) {
+ String js = getCollectStateJs();
+ PendingJavaScriptResult res = UI.getCurrent().getPage().executeJs(js);
+ res.then(String.class, json -> {
+ try {
+ templateService.saveOrUpdate(templateName, json);
+ Notification.show("Template gespeichert", 2000, Notification.Position.BOTTOM_END);
+ } catch (Exception ex) {
+ Notification.show("Speichern fehlgeschlagen: " + ex.getMessage(), 4000, Notification.Position.MIDDLE);
+ }
+ // Danach PDF exportieren (Dateiname aus Template-Namen)
+ exportCanvasToPdf(templateName);
+ });
+ }
+
+ private String getCollectStateJs() {
+ return "(function(){\n" +
+ " const root = document.getElementById('pdf-canvas');\n" +
+ " if(!root){ return JSON.stringify({items:[]}); }\n" +
+ " root.classList.add('capturing');\n" +
+ " try {\n" +
+ " const rootRect = root.getBoundingClientRect();\n" +
+ " const items = Array.from(root.querySelectorAll('.canvas-frame')).map(f=>{\n" +
+ " const r = f.getBoundingClientRect();\n" +
+ " const x = Math.round(r.left - rootRect.left);\n" +
+ " const y = Math.round(r.top - rootRect.top);\n" +
+ " const width = Math.round(r.width);\n" +
+ " const height = Math.round(r.height);\n" +
+ " const img = f.querySelector('img');\n" +
+ " if(img){\n" +
+ " return {type:'image', x, y, width, height, dataUrl: img.src};\n" +
+ " } else {\n" +
+ " const editor = f.querySelector('.text-editor');\n" +
+ " const cs = editor ? getComputedStyle(editor) : null;\n" +
+ " return {type:'text', x, y, width, height, html: editor?editor.innerHTML:'', style: cs?{fontSize: cs.fontSize, color: cs.color}:{}};\n" +
+ " }\n" +
+ " });\n" +
+ " return JSON.stringify({items});\n" +
+ " } finally {\n" +
+ " root.classList.remove('capturing');\n" +
+ " }\n" +
+ "})()";
+ }
+
+ private Div createPalette() {
+ Div palette = new Div();
+ palette.addClassName("pdf-palette");
+
+ H3 title = new H3("Elementpalette");
+ title.addClassName("palette-title");
+ palette.add(title);
+
+ // Text-Tool
+ Div textTool = new Div();
+ textTool.addClassName("tool-item");
+ textTool.getElement().setProperty("draggable", true);
+ textTool.getElement().setAttribute("data-tool", "text");
+ Icon textIcon = VaadinIcon.FONT.create();
+ Span textLabel = new Span("Text hinzufügen");
+ textTool.add(textIcon, textLabel);
+ enableDragSource(textTool);
+
+ // Bild-Tool
+ Div imageTool = new Div();
+ imageTool.addClassName("tool-item");
+ imageTool.getElement().setProperty("draggable", true);
+ imageTool.getElement().setAttribute("data-tool", "image");
+ Icon imageIcon = VaadinIcon.PICTURE.create();
+ Span imageLabel = new Span("Bild hinzufügen");
+ imageTool.add(imageIcon, imageLabel);
+ enableDragSource(imageTool);
+
+ palette.add(textTool, imageTool);
+ return palette;
+ }
+
+ private void enableDragSource(Div toolItem) {
+ // HTML5-Drag-Daten setzen (Typ: text/image)
+ toolItem.getElement().executeJs(
+ "this.addEventListener('dragstart', (e) => {" +
+ " e.dataTransfer.setData('application/x-tool', this.dataset.tool);" +
+ " e.dataTransfer.effectAllowed = 'copy';" +
+ "});"
+ );
+ }
+
+ private void exportCanvasToPdf(String templateName) {
+ // html2canvas laden (falls nicht vorhanden) und INNERES Canvas erfassen
+ String js = "const el = document.getElementById('pdf-canvas');\n" +
+ "el.classList.add('capturing');\n" +
+ "const capture = () => html2canvas(el).then(c => c.toDataURL('image/png'));\n" +
+ "if (window.html2canvas) {\n" +
+ " return capture().then(r => { el.classList.remove('capturing'); return r; }).catch(err => { el.classList.remove('capturing'); return null; });\n" +
+ "}\n" +
+ "return new Promise(resolve => {\n" +
+ " const s = document.createElement('script');\n" +
+ " s.src = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js';\n" +
+ " s.onload = () => capture().then(r => { el.classList.remove('capturing'); resolve(r); }).catch(() => { el.classList.remove('capturing'); resolve(null); });\n" +
+ " s.onerror = () => { el.classList.remove('capturing'); resolve(null); };\n" +
+ " document.head.appendChild(s);\n" +
+ "});";
+
+ PendingJavaScriptResult result = UI.getCurrent().getPage().executeJs(js);
+ result.then(String.class, dataUrl -> {
+ try {
+ if (dataUrl == null || !dataUrl.startsWith("data:image/png;base64,")) {
+ Notification.show("Fehler beim Erfassen des Canvas", 3000, Notification.Position.MIDDLE);
+ return;
+ }
+ String base64 = dataUrl.substring(dataUrl.indexOf(',') + 1);
+ byte[] pngBytes = Base64.getDecoder().decode(base64.getBytes(StandardCharsets.UTF_8));
+ byte[] pdfBytes = pdfService.generatePdfFromImage(pngBytes);
+
+ String fn = (templateName == null || templateName.isBlank() ? "canvas-export" : templateName.trim())
+ .replaceAll("[\\\\/:*?\"<>|]+", "_");
+ if (!fn.toLowerCase().endsWith(".pdf")) {
+ fn = fn + ".pdf";
+ }
+
+ StreamResource resource = new StreamResource(fn, () -> new ByteArrayInputStream(pdfBytes));
+ resource.setContentType("application/pdf");
+ resource.setCacheTime(0);
+
+ StreamRegistration reg = VaadinSession.getCurrent().getResourceRegistry().registerResource(resource);
+ String url = reg.getResourceUri().toString();
+ // Öffnet den Download direkt – i.d.R. zuverlässig und nicht durch Popup-Blocker verhindert
+ UI.getCurrent().getPage().open(url);
+
+ Notification.show("PDF wurde erstellt und heruntergeladen", 3000, Notification.Position.BOTTOM_END);
+ } catch (Exception ex) {
+ Notification.show("Fehler bei PDF-Erzeugung: " + ex.getMessage(), 5000, Notification.Position.MIDDLE);
+ }
+ });
+ }
+
+ // Canvas-Komponente mit Drop-Handling und Frame-Erzeugung
+ static class PdfCanvas extends Div {
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private final Div inner;
+
+ PdfCanvas() {
+ addClassName("pdf-canvas-wrapper");
+
+ // Inneres Canvas-Element (relative, A4-Seitenverhältnis)
+ inner = new Div();
+ inner.addClassName("pdf-canvas");
+ inner.setId("pdf-canvas");
+ add(inner);
+
+ enableDrop(inner);
+ enableCanvasDeactivation(inner);
+ }
+
+ private void enableDrop(Div target) {
+ // Dragover/Drops auf dem Canvas erfassen und an Server melden
+ target.getElement().executeJs(
+ "const canvas = this;\n" +
+ "canvas.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });\n" +
+ "canvas.addEventListener('drop', e => {\n" +
+ " e.preventDefault();\n" +
+ " const rect = canvas.getBoundingClientRect();\n" +
+ " const x = Math.round(e.clientX - rect.left);\n" +
+ " const y = Math.round(e.clientY - rect.top);\n" +
+ " const tool = e.dataTransfer.getData('application/x-tool');\n" +
+ " if (canvas.parentElement && canvas.parentElement.$server) {\n" +
+ " canvas.parentElement.$server.onDrop(tool, x, y);\n" +
+ " }\n" +
+ "});"
+ );
+ }
+
+ private void enableCanvasDeactivation(Div target) {
+ target.getElement().executeJs(
+ "const canvas = this;\n" +
+ "function deactivateAll(){\n" +
+ " canvas.querySelectorAll('.canvas-frame.active').forEach(f => f.classList.remove('active'));\n" +
+ " canvas.querySelectorAll('.text-editor').forEach(ed => { if (ed.blur) ed.blur(); });\n" +
+ "}\n" +
+ "canvas.addEventListener('mousedown', (e) => {\n" +
+ " const el = e.target;\n" +
+ " if (el === canvas || !el.closest('.canvas-frame')) { deactivateAll(); }\n" +
+ "});\n" +
+ "canvas.addEventListener('touchstart', (e) => {\n" +
+ " const el = e.target;\n" +
+ " if (el === canvas || !el.closest('.canvas-frame')) { deactivateAll(); }\n" +
+ "}, { passive: true });"
+ );
+ }
+
+ @com.vaadin.flow.component.ClientCallable
+ private void onDrop(String tool, int x, int y) {
+ if (Objects.equals(tool, "text")) {
+ addTextFrame(x, y);
+ } else if (Objects.equals(tool, "image")) {
+ promptImageUpload(x, y);
+ }
+ }
+
+ private void addTextFrame(int x, int y) {
+ Div frame = new Div();
+ frame.addClassName("canvas-frame");
+ frame.addClassName("text-frame");
+ frame.getStyle().set("left", x + "px");
+ frame.getStyle().set("top", y + "px");
+ frame.getStyle().set("width", "300px");
+ frame.getStyle().set("height", "140px");
+
+ // Move-Handle (oben links)
+ Div moveHandle = new Div();
+ moveHandle.addClassName("frame-handle-move");
+ moveHandle.setText("⠿");
+
+ // Toolbar
+ HorizontalLayout toolbar = new HorizontalLayout();
+ toolbar.addClassName("text-toolbar");
+ toolbar.setPadding(false);
+ toolbar.setSpacing(true);
+
+ Button bBold = new Button(new Icon(VaadinIcon.BOLD));
+ bBold.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE);
+ Button bItalic = new Button(new Icon(VaadinIcon.ITALIC));
+ bItalic.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE);
+ Button bUnderline = new Button(new Icon(VaadinIcon.UNDERLINE));
+ bUnderline.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE);
+ Button bSmaller = new Button(new Icon(VaadinIcon.MINUS));
+ bSmaller.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE);
+ Button bLarger = new Button(new Icon(VaadinIcon.PLUS));
+ bLarger.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE);
+ Button cBlack = new Button(); cBlack.getElement().getThemeList().add("tertiary-inline"); cBlack.getStyle().set("background","#000"); cBlack.getStyle().set("width","16px"); cBlack.getStyle().set("height","16px");
+ Button cRed = new Button(); cRed.getElement().getThemeList().add("tertiary-inline"); cRed.getStyle().set("background","#e11"); cRed.getStyle().set("width","16px"); cRed.getStyle().set("height","16px");
+ Button cBlue = new Button(); cBlue.getElement().getThemeList().add("tertiary-inline"); cBlue.getStyle().set("background","#16c"); cBlue.getStyle().set("width","16px"); cBlue.getStyle().set("height","16px");
+
+ Div editor = new Div();
+ editor.addClassName("text-editor");
+ editor.getElement().setAttribute("contenteditable", "true");
+ editor.getElement().setAttribute("tabindex", "0");
+ editor.getStyle().set("padding", "8px");
+ editor.getStyle().set("font-size", "16px");
+ editor.getStyle().set("line-height", "1.3");
+ editor.setText("Text hier eingeben…");
+
+ // Button-Aktionen (clientseitig auf Selektion)
+ bBold.addClickListener(e -> editor.getElement().executeJs("document.execCommand('bold')"));
+ bItalic.addClickListener(e -> editor.getElement().executeJs("document.execCommand('italic')"));
+ bUnderline.addClickListener(e -> editor.getElement().executeJs("document.execCommand('underline')"));
+ bSmaller.addClickListener(e -> editor.getElement().executeJs(
+ "const s = parseInt(getComputedStyle(this).fontSize)||16; this.style.fontSize=(Math.max(8,s-2))+'px';"));
+ bLarger.addClickListener(e -> editor.getElement().executeJs(
+ "const s = parseInt(getComputedStyle(this).fontSize)||16; this.style.fontSize=(s+2)+'px';"));
+ cBlack.addClickListener(e -> editor.getElement().executeJs("document.execCommand('foreColor', false, '#000000')"));
+ cRed.addClickListener(e -> editor.getElement().executeJs("document.execCommand('foreColor', false, '#cc1111')"));
+ cBlue.addClickListener(e -> editor.getElement().executeJs("document.execCommand('foreColor', false, '#1166cc')"));
+
+ toolbar.add(bBold, bItalic, bUnderline, bSmaller, bLarger, cBlack, cRed, cBlue);
+
+ frame.add(moveHandle, toolbar, editor);
+ // Frame dem inneren Canvas hinzufügen
+ inner.add(frame);
+ // Drag-to-move aktivieren
+ enableFrameDragging(frame);
+ // Aktivierungsverhalten
+ enableFrameActivation(frame);
+ }
+
+ private void promptImageUpload(int x, int y) {
+ Dialog dlg = new Dialog();
+ dlg.setHeaderTitle("Bild hochladen");
+ dlg.setModal(true);
+ MemoryBuffer buffer = new MemoryBuffer();
+ Upload upload = new Upload(buffer);
+ upload.setAcceptedFileTypes("image/*");
+ upload.setMaxFiles(1);
+ upload.addSucceededListener(succeededEvent -> {
+ try {
+ byte[] bytes = buffer.getInputStream().readAllBytes();
+ addImageFrame(x, y, bytes, succeededEvent.getFileName());
+ dlg.close();
+ } catch (IOException ex) {
+ Notification.show("Upload fehlgeschlagen: " + ex.getMessage(), 5000, Notification.Position.MIDDLE);
+ }
+ });
+ Button cancel = new Button("Abbrechen", e -> dlg.close());
+ VerticalLayout content = new VerticalLayout(new Span("Bitte eine Bilddatei auswählen."), upload);
+ content.setPadding(false);
+ content.setSpacing(true);
+ dlg.add(content);
+ dlg.getFooter().add(cancel);
+ dlg.open();
+ }
+
+ private void addImageFrame(int x, int y, byte[] imageBytes, String filename) {
+ Div frame = new Div();
+ frame.addClassName("canvas-frame");
+ frame.addClassName("image-frame");
+ frame.getStyle().set("left", x + "px");
+ frame.getStyle().set("top", y + "px");
+ frame.getStyle().set("width", "260px");
+ frame.getStyle().set("height", "180px");
+
+ String base64 = Base64.getEncoder().encodeToString(imageBytes);
+ Image img = new Image("data:image/png;base64," + base64, filename == null ? "Bild" : filename);
+ img.getStyle().set("width", "100%");
+ img.getStyle().set("height", "100%");
+ img.getStyle().set("object-fit", "contain");
+
+ // Move-Handle (oben links)
+ Div moveHandle = new Div();
+ moveHandle.addClassName("frame-handle-move");
+ moveHandle.setText("⠿");
+
+ // Nur Bild (Toolbar für Bilder aktuell nicht notwendig)
+ frame.add(moveHandle, img);
+ inner.add(frame);
+ // Drag-to-move aktivieren
+ enableFrameDragging(frame);
+ // Aktivierungsverhalten
+ enableFrameActivation(frame);
+ }
+
+ private void enableFrameDragging(Div frame) {
+ frame.getElement().executeJs(
+ "const frame = this;\n" +
+ "const root = document.getElementById('pdf-canvas');\n" +
+ "let startX = 0, startY = 0, startLeft = 0, startTop = 0;\n" +
+ "frame.__dragMoved = false;\n" +
+ "frame.__downOutsideEditor = false;\n" +
+ "function pxToInt(v){ const n = parseInt(v); return isNaN(n)?0:n; }\n" +
+ "function placeCaretAtEnd(el){ try{ const range=document.createRange(); range.selectNodeContents(el); range.collapse(false); const sel=window.getSelection(); sel.removeAllRanges(); sel.addRange(range);}catch(err){} }\n" +
+ "function onDown(e){\n" +
+ " const target = e.target;\n" +
+ " const isHandle = target && target.closest && target.closest('.frame-handle-move');\n" +
+ " // Toolbar-Klicks nie zum Ziehen verwenden\n" +
+ " if (target && target.closest && target.closest('.text-toolbar')) { return; }\n" +
+ " // Klick im Editor soll immer fokussieren und nicht ziehen\n" +
+ " const editor = frame.querySelector('.text-editor');\n" +
+ " const clickedInEditor = target && target.closest && target.closest('.text-editor');\n" +
+ " if (!isHandle && clickedInEditor) { return; }\n" +
+ " const p = (e.touches && e.touches[0]) ? e.touches[0] : e;\n" +
+ " // Nicht ziehen, wenn in der Nähe der Resize-Kanten geklickt wurde (damit Resize funktioniert)\n" +
+ " const rect = frame.getBoundingClientRect();\n" +
+ " const offsetX = p.clientX - rect.left;\n" +
+ " const offsetY = p.clientY - rect.top;\n" +
+ " const edge = 14;\n" +
+ " if (!isHandle && (offsetX < edge || offsetY < edge || (rect.width - offsetX) < edge || (rect.height - offsetY) < edge)) { return; }\n" +
+ " frame.__downOutsideEditor = !isHandle;\n" +
+ " e.preventDefault();\n" +
+ " startX = p.clientX; startY = p.clientY;\n" +
+ " startLeft = pxToInt(frame.style.left);\n" +
+ " startTop = pxToInt(frame.style.top);\n" +
+ " frame.__dragMoved = false;\n" +
+ " frame.style.zIndex = String((parseInt(frame.style.zIndex)||0) + 1);\n" +
+ " document.addEventListener('mousemove', onMove);\n" +
+ " document.addEventListener('mouseup', onUp, { once: true });\n" +
+ " document.addEventListener('touchmove', onMove, { passive: false });\n" +
+ " document.addEventListener('touchend', onUp, { once: true });\n" +
+ "}\n" +
+ "function onMove(e){\n" +
+ " const p = (e.touches && e.touches[0]) ? e.touches[0] : e;\n" +
+ " if (!p) return;\n" +
+ " e.preventDefault();\n" +
+ " const dx = p.clientX - startX;\n" +
+ " const dy = p.clientY - startY;\n" +
+ " if (Math.abs(dx) > 2 || Math.abs(dy) > 2) { frame.__dragMoved = true; }\n" +
+ " const rootRect = root.getBoundingClientRect();\n" +
+ " const maxLeft = rootRect.width - frame.offsetWidth;\n" +
+ " const maxTop = rootRect.height - frame.offsetHeight;\n" +
+ " let newLeft = Math.min(Math.max(0, startLeft + dx), maxLeft);\n" +
+ " let newTop = Math.min(Math.max(0, startTop + dy), maxTop);\n" +
+ " frame.style.left = Math.round(newLeft) + 'px';\n" +
+ " frame.style.top = Math.round(newTop) + 'px';\n" +
+ "}\n" +
+ "function onUp(){\n" +
+ " document.removeEventListener('mousemove', onMove);\n" +
+ " document.removeEventListener('touchmove', onMove);\n" +
+ " // Plain Click auf Frame (nicht im Editor, kein Drag): Editor fokussieren\n" +
+ " if (!frame.__dragMoved && frame.__downOutsideEditor){ const ed = frame.querySelector('.text-editor'); if (ed){ ed.focus(); placeCaretAtEnd(ed); } }\n" +
+ " // dragMoved-Flag nach dem Click-Zyklus zurücksetzen\n" +
+ " setTimeout(()=>{ frame.__dragMoved = false; frame.__downOutsideEditor = false; }, 0);\n" +
+ "}\n" +
+ "frame.addEventListener('mousedown', onDown);\n" +
+ "frame.addEventListener('touchstart', onDown, { passive: false });\n"
+ );
+ }
+
+ private void enableFrameActivation(Div frame) {
+ frame.getElement().executeJs(
+ "const frame = this;\n" +
+ "function setActive(){\n" +
+ " const root = document.getElementById('pdf-canvas');\n" +
+ " if (!root) return;\n" +
+ " root.querySelectorAll('.canvas-frame.active').forEach(f => f.classList.remove('active'));\n" +
+ " frame.classList.add('active');\n" +
+ "}\n" +
+ "frame.addEventListener('mousedown', ()=>{ setActive(); });\n" +
+ "frame.addEventListener('touchstart', ()=>{ setActive(); }, { passive: true });\n" +
+ "const editor = frame.querySelector('.text-editor');\n" +
+ "if (editor){\n" +
+ " editor.addEventListener('focus', ()=> setActive());\n" +
+ " editor.addEventListener('mousedown', ()=> setActive());\n" +
+ "}\n" +
+ "function placeCaretAtEnd(el){ try{ const range=document.createRange(); range.selectNodeContents(el); range.collapse(false); const sel=window.getSelection(); sel.removeAllRanges(); sel.addRange(range);}catch(err){} }\n" +
+ "frame.addEventListener('click', (e)=>{\n" +
+ " const t = e.target;\n" +
+ " if (t && t.closest && t.closest('.text-toolbar')) return;\n" +
+ " const ed = frame.querySelector('.text-editor');\n" +
+ " if (!ed) return;\n" +
+ " // Wenn nicht direkt im Editor geklickt und kein Drag stattfand: Editor fokussieren\n" +
+ " if (!(t && t.closest && t.closest('.text-editor')) && !frame.__dragMoved){\n" +
+ " ed.focus();\n" +
+ " placeCaretAtEnd(ed);\n" +
+ " }\n" +
+ "});"
+ );
+ }
+ }
+}
diff --git a/src/main/java/de/assecutor/votianlt/repository/PdfTemplateRepository.java b/src/main/java/de/assecutor/votianlt/repository/PdfTemplateRepository.java
new file mode 100644
index 0000000..6a64fa0
--- /dev/null
+++ b/src/main/java/de/assecutor/votianlt/repository/PdfTemplateRepository.java
@@ -0,0 +1,10 @@
+package de.assecutor.votianlt.repository;
+
+import de.assecutor.votianlt.model.PdfTemplate;
+import org.springframework.data.mongodb.repository.MongoRepository;
+
+import java.util.Optional;
+
+public interface PdfTemplateRepository extends MongoRepository {
+ Optional findByName(String name);
+}