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:
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user