Merge branch 'feat/migr-DxReportViewer' of http://git.dd:3000/AppStd/EnvelopeGenerator into feat/migr-DxReportViewer

This commit is contained in:
2026-07-01 15:43:33 +02:00
3 changed files with 473 additions and 0 deletions

View File

@@ -0,0 +1,354 @@
@page "/envelope/editor"
@rendermode InteractiveServer
@using DevExpress.Blazor.PdfViewer
@using DevExpress.Blazor.Reporting.Models
@using DevExpress.Blazor
@using EnvelopeGenerator.Server.Client.Services
@using Microsoft.AspNetCore.Components.Forms
@inject IJSRuntime JSRuntime
@inject AppVersionService AppVersion
@inject ILogger<EnvelopeSenderEditorPage> Logger
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
<script src="@AppVersion.GetVersionedUrl("js/envelope-editor.js")"></script>
<div class="envelope-viewer-layout">
@* ── Action Bar ── *@
<div class="envelope-action-bar">
<div class="envelope-action-bar__inner"
style="flex-direction: row; align-items: center; padding: 0.35rem 1.5rem; gap: 0.75rem;">
@* Left: Title *@
<div style="flex: 1; min-width: 0; display: flex; align-items: center; gap: 0.75rem;">
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937; white-space: nowrap;">
Neues Dokument
</div>
@if (_pdfLoaded)
{
<span style="font-size: 0.7rem; color: #6b7280;">@_fileName</span>
@if (_signatureFields.Count > 0)
{
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem;
background: #ede9fe; border-radius: 0.25rem; color: #6d28d9;
font-weight: 500; font-size: 0.7rem; white-space: nowrap;">
@_signatureFields.Count Signaturfeld@(_signatureFields.Count != 1 ? "er" : "")
</span>
}
}
</div>
@* Right: Buttons *@
<div style="display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0;">
@* PDF Upload *@
<label class="pdf-toolbar__btn"
title="PDF hochladen"
style="cursor: pointer; display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.75rem; font-size: 0.75rem; font-weight: 500;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z" />
</svg>
PDF hochladen
<InputFile OnChange="OnPdfFileSelectedAsync"
accept=".pdf"
style="display: none;" />
</label>
@if (_pdfLoaded)
{
@* Toggle placement mode *@
<button class="pdf-toolbar__btn @(_placementMode ? "pdf-toolbar__btn--signature-change-active" : "pdf-toolbar__btn--signature-change")"
@onclick="TogglePlacementMode"
title="@(_placementMode ? "Platzierungsmodus beenden" : "Signaturfeld hinzufügen")">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10z" />
</svg>
<span class="pdf-toolbar__btn-text">
@(_placementMode ? "Abbrechen" : "+ Signaturfeld")
</span>
</button>
@* Clear all fields *@
@if (_signatureFields.Count > 0)
{
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
@onclick="ClearAllFields"
title="Alle Signaturfelder entfernen">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1 0-2h3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1h3a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3h11V2h-11v1z" />
</svg>
</button>
}
@* Save *@
<button class="pdf-toolbar__btn pdf-toolbar__btn--success"
@onclick="SaveAsync"
title="Speichern"
style="background: linear-gradient(135deg,#059669,#047857); color:#fff; border-radius:6px; padding:0.3rem 0.75rem; font-size:0.75rem; font-weight:600; border:none;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
</svg>
Speichern
</button>
}
</div>
</div>
@* Placement mode hint bar *@
@if (_placementMode)
{
<div style="background: #4F46E5; color: white; font-size: 0.75rem; font-weight: 500;
padding: 0.3rem 1.5rem; text-align: center; letter-spacing: 0.01em;">
📌 Klicken Sie auf die gewünschte Stelle im Dokument, um ein Signaturfeld zu platzieren.
</div>
}
</div>
@* ── Content ── *@
<div class="envelope-content" style="padding: 0; overflow: hidden;">
@if (!_pdfLoaded)
{
@* Empty state *@
<div class="d-flex justify-content-center align-items-center h-100">
<div class="text-center" style="max-width: 420px;">
<div style="width: 72px; height: 72px; background: rgba(255,255,255,0.15);
border-radius: 50%; display: flex; align-items: center;
justify-content: center; margin: 0 auto 1.25rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" fill="white" viewBox="0 0 16 16">
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z" />
</svg>
</div>
<h5 class="text-white mb-2">Kein Dokument geladen</h5>
<p class="text-white mb-4" style="opacity: 0.75; font-size: 0.85rem;">
Laden Sie eine PDF-Datei hoch, um Signaturfelder zu platzieren.
</p>
<label class="btn btn-light btn-sm" style="cursor: pointer; font-weight: 500;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z" />
</svg>
PDF hochladen
<InputFile OnChange="OnPdfFileSelectedAsync" accept=".pdf" style="display: none;" />
</label>
</div>
</div>
}
else if (_errorMessage is not null)
{
<div class="error-container">
<div class="alert alert-danger shadow-lg m-4">
<strong>Fehler:</strong> @_errorMessage
</div>
</div>
}
else
{
@* PDF viewer + overlay wrapper *@
<div id="pdf-editor-wrapper" class="pdf-editor-wrapper"
style="position: relative; width: 100%; height: 100%; overflow: auto;">
@* DxPdfViewer — zoom fixed to 1.0 for reliable coordinate mapping *@
<DxPdfViewer @ref="_pdfViewer"
DocumentContent="@_pdfBytes"
ZoomLevel="1.0"
IsSinglePagePreview="false"
CssClass="sender-editor-pdf-viewer" />
@* Transparent overlay for click capture (active only in placement mode) *@
<div id="pdf-editor-overlay"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;
z-index: 20;
pointer-events: @(_placementMode ? "auto" : "none");
cursor: @(_placementMode ? "crosshair" : "default");"
@onclick="OnOverlayClickAsync">
@* Render placed signature field placeholders *@
@foreach (var field in _signatureFields)
{
var f = field; // capture for lambda
<div style="position: absolute;
left: @(f.DisplayX.ToString("F1", System.Globalization.CultureInfo.InvariantCulture))px;
top: @(f.DisplayY.ToString("F1", System.Globalization.CultureInfo.InvariantCulture))px;
width: @(SigDisplayW)px;
height: @(SigDisplayH)px;
border: 2px dashed #4F46E5;
background: rgba(79,70,229,0.10);
border-radius: 4px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
pointer-events: auto;
cursor: default;"
@onclick:stopPropagation="true">
<span style="font-size: 0.6rem; font-weight: 700; color: #4F46E5;
letter-spacing: 0.05em; text-transform: uppercase;">
Unterschrift
</span>
<span style="font-size: 0.55rem; color: #6d28d9; opacity: 0.8;">
S.@f.Page
</span>
<button @onclick="() => RemoveField(f)"
style="position: absolute; top: 2px; right: 4px;
background: none; border: none; cursor: pointer;
color: #6d28d9; font-size: 0.75rem; line-height: 1;
padding: 0; opacity: 0.7;"
title="Entfernen">
×
</button>
</div>
}
</div>
</div>
}
</div>
</div>
@code {
// ── Constants ──
// Signature field size in PDF points (fixed): 1.77" × 1.96" × 72 pt/inch
const double SigWidthPt = 1.77 * 72; // 127.44 pt
const double SigHeightPt = 1.96 * 72; // 141.12 pt
// Display size of the overlay placeholder (pixels at zoom=1.0).
// At zoom=1.0, 1 CSS px ≈ 1 pt in the DxPdfViewer render.
const double SigDisplayW = SigWidthPt;
const double SigDisplayH = SigHeightPt;
// ── State ──
DxPdfViewer? _pdfViewer;
byte[]? _pdfBytes;
bool _pdfLoaded = false;
string _fileName = string.Empty;
string? _errorMessage;
bool _placementMode = false;
List<SignatureFieldDraft> _signatureFields = [];
// ── PDF upload ──
async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e)
{
_errorMessage = null;
var file = e.File;
if (file is null) return;
if (!file.Name.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
{
_errorMessage = "Bitte wählen Sie eine PDF-Datei aus.";
return;
}
try
{
// Max 50 MB
const long maxBytes = 50 * 1024 * 1024;
using var ms = new System.IO.MemoryStream();
await file.OpenReadStream(maxBytes).CopyToAsync(ms);
_pdfBytes = ms.ToArray();
_fileName = file.Name;
_pdfLoaded = true;
_signatureFields.Clear();
_placementMode = false;
Logger.LogInformation("PDF loaded: {Name} ({Size} bytes)", _fileName, _pdfBytes.Length);
}
catch (Exception ex)
{
_errorMessage = $"Fehler beim Laden der Datei: {ex.Message}";
Logger.LogError(ex, "Failed to load PDF file");
}
}
// ── Placement mode ──
void TogglePlacementMode() => _placementMode = !_placementMode;
void ClearAllFields()
{
_signatureFields.Clear();
_placementMode = false;
}
void RemoveField(SignatureFieldDraft field) => _signatureFields.Remove(field);
// ── Overlay click → add signature field ──
async Task OnOverlayClickAsync(MouseEventArgs e)
{
if (!_placementMode) return;
// Get overlay container bounds via JS
var coords = await JSRuntime.InvokeAsync<OverlayCoords>(
"envelopeEditor.getClickCoords", "pdf-editor-overlay",
e.ClientX, e.ClientY);
if (coords is null) return;
// At zoom=1.0: container pixels ≈ PDF display pixels.
// DxPdfViewer renders at 96 dpi by default; PDF points = 72 dpi.
// Scale factor: 96/72 = 1.333 → px / 1.333 = pt
const double pxToPt = 72.0 / 96.0;
double xPt = coords.RelX * pxToPt;
double yPt = coords.RelY * pxToPt;
// Active page: DxPdfViewer.ActivePageIndex is 0-based
int page = (_pdfViewer?.ActivePageIndex ?? 0) + 1;
// Display position (px on overlay) — keep in px for CSS
double displayX = coords.RelX;
double displayY = coords.RelY;
// Prevent placing outside bounds
if (displayX < 0 || displayY < 0) return;
if (displayX + SigDisplayW > coords.ContainerW) displayX = coords.ContainerW - SigDisplayW;
if (displayY + SigDisplayH > coords.ContainerH) displayY = coords.ContainerH - SigDisplayH;
var field = new SignatureFieldDraft(xPt, yPt, page, displayX, displayY);
_signatureFields.Add(field);
Logger.LogInformation(
"Signature field added: Page={Page} X={X:F1}pt Y={Y:F1}pt",
page, xPt, yPt);
// Exit placement mode after one click (user can re-click button for next)
_placementMode = false;
}
// ── Save ──
async Task SaveAsync()
{
if (_signatureFields.Count == 0)
{
await JSRuntime.InvokeVoidAsync("console.log",
"[SenderEditor] No signature fields to save.");
return;
}
foreach (var f in _signatureFields)
{
await JSRuntime.InvokeVoidAsync("console.log",
$"[SenderEditor] Field: Page={f.Page} X={f.XPt:F2}pt ({f.XPt/72:F3}in) Y={f.YPt:F2}pt ({f.YPt/72:F3}in)");
}
await JSRuntime.InvokeVoidAsync("console.log",
$"[SenderEditor] Total fields: {_signatureFields.Count}");
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!_pdfLoaded || _errorMessage is not null)
return;
await JSRuntime.InvokeVoidAsync(
"envelopeEditor.syncOverlayToPage",
"pdf-editor-wrapper",
"pdf-editor-overlay");
}
// ── Models ──
record SignatureFieldDraft(double XPt, double YPt, int Page, double DisplayX, double DisplayY);
record OverlayCoords(double RelX, double RelY, double ContainerW, double ContainerH);
}

View File

@@ -51,6 +51,33 @@
overflow: auto;
}
.pdf-editor-wrapper {
position: relative;
min-height: 100%;
}
.sender-editor-pdf-viewer {
width: 100%;
height: 100%;
}
.sender-editor-pdf-viewer .dxbrv-document-surface {
display: flex;
flex-direction: column;
align-items: center;
}
.sender-editor-pdf-viewer .dxbrv-report-preview-content-flex-item {
width: 100%;
display: flex;
justify-content: center;
}
.sender-editor-pdf-viewer .dxbrv-report-preview-content {
margin-left: auto;
margin-right: auto;
}
.pdf-viewer-container {
height: 100%;
display: flex;

View File

@@ -0,0 +1,92 @@
window.envelopeEditor = {
_overlaySyncState: {},
/**
* Returns click coordinates relative to the overlay element.
* @param {string} overlayId - The id of the overlay div
* @param {number} clientX - MouseEventArgs.ClientX from Blazor
* @param {number} clientY - MouseEventArgs.ClientY from Blazor
* @returns {{ relX, relY, containerW, containerH }}
*/
getClickCoords: function (overlayId, clientX, clientY) {
const el = document.getElementById(overlayId);
if (!el) return null;
const rect = el.getBoundingClientRect();
return {
relX: clientX - rect.left,
relY: clientY - rect.top,
containerW: rect.width,
containerH: rect.height
};
},
syncOverlayToPage: function (wrapperId, overlayId) {
const wrapper = document.getElementById(wrapperId);
const overlay = document.getElementById(overlayId);
if (!wrapper || !overlay) {
return;
}
const existing = window.envelopeEditor._overlaySyncState[overlayId];
if (existing) {
return existing.sync();
}
const findTarget = (currentWrapper) => {
const page = currentWrapper.querySelector(".dxbrv-report-preview-content");
if (page) {
return page;
}
return currentWrapper.querySelector(".dxbrv-report-preview-content-img") ||
currentWrapper.querySelector("img.dxbrv-report-preview-content-img") ||
currentWrapper.querySelector(".dxbrv-document-surface img");
};
const sync = () => {
const currentWrapper = document.getElementById(wrapperId);
const currentOverlay = document.getElementById(overlayId);
if (!currentWrapper || !currentOverlay) {
return;
}
const target = findTarget(currentWrapper);
if (!target) {
currentOverlay.style.left = "0px";
currentOverlay.style.top = "0px";
currentOverlay.style.width = "0px";
currentOverlay.style.height = "0px";
return;
}
const wrapperRect = currentWrapper.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
currentOverlay.style.left = `${targetRect.left - wrapperRect.left + currentWrapper.scrollLeft}px`;
currentOverlay.style.top = `${targetRect.top - wrapperRect.top + currentWrapper.scrollTop}px`;
currentOverlay.style.width = `${targetRect.width}px`;
currentOverlay.style.height = `${targetRect.height}px`;
};
const scheduleSync = () => requestAnimationFrame(sync);
const observer = new MutationObserver(scheduleSync);
observer.observe(wrapper, { childList: true, subtree: true, attributes: true });
wrapper.addEventListener("scroll", scheduleSync, { passive: true });
window.addEventListener("resize", scheduleSync);
window.envelopeEditor._overlaySyncState[overlayId] = {
sync,
observer
};
sync();
setTimeout(sync, 50);
setTimeout(sync, 150);
setTimeout(sync, 400);
}
};