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); +}