Add envelope editor with PDF upload and signature tools

Introduced a new Blazor page `EnvelopeSenderEditorPage.razor` for editing envelopes with an interactive interface. Integrated `DxPdfViewer` for rendering PDFs and added functionality for uploading, viewing, and interacting with PDF files.

Key features:
- Action bar with buttons for uploading PDFs, toggling signature placement mode, clearing fields, and saving.
- Placement mode for adding signature fields via an overlay, with visual placeholders.
- JavaScript interop (`envelope-editor.js`) for precise click coordinate mapping.
- Error handling for unsupported file types and size limits (50 MB).
- Logging for debugging key actions like PDF uploads and field placements.

Defined constants for accurate signature field dimensions and scaling. Added models (`SignatureFieldDraft`, `OverlayCoords`) to manage state and interactions.
This commit is contained in:
2026-07-01 11:25:04 +02:00
parent 76ff3e47e1
commit 2a9bbb3fe5
2 changed files with 364 additions and 0 deletions

View File

@@ -0,0 +1,343 @@
@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"
style="position: relative; width: 100%; height: 100%; overflow: hidden;">
@* DxPdfViewer — zoom fixed to 1.0 for reliable coordinate mapping *@
<DxPdfViewer @ref="_pdfViewer"
DocumentContent="@_pdfBytes"
ZoomLevel="1.0"
IsSinglePagePreview="false"
CssClass="w-100 h-100" />
@* 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}");
}
// ── 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

@@ -0,0 +1,21 @@
window.envelopeEditor = {
/**
* 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
};
}
};