Refactor and enhance PDF signature editor

- Added `IMemoryCache` for session persistence and caching.
- Introduced session persistence via query parameter (`esid`).
- Replaced overlay-based click handling with normalized PDF coordinates.
- Added `PdfSharp` integration to render signature placeholders.
- Updated button behavior for receiver-specific signature placement.
- Improved receiver popup validation and email suggestion handling.
- Removed unused overlay synchronization logic.
- Refactored CSS for better PDF viewer layout and toolbar alignment.
- Enhanced logging with additional context for signature actions.
- General code cleanup for readability and maintainability.
This commit is contained in:
2026-07-01 22:50:47 +02:00
parent 10f65e583a
commit 74da6e37b0
3 changed files with 374 additions and 275 deletions

View File

@@ -6,13 +6,16 @@
@using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Server.Services
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.Extensions.Caching.Memory
@inject IJSRuntime JSRuntime
@inject NavigationManager NavigationManager
@inject AppVersionService AppVersion
@inject ILogger<EnvelopeSenderEditorPage> Logger
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
@inject IMemoryCache MemoryCache
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<link href="_content/DevExpress.Blazor.Viewer/css/dx-blazor-viewer-components.bs5.css" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
<script src="@AppVersion.GetVersionedUrl("js/envelope-editor.js")"></script>
@@ -81,9 +84,10 @@
}
</div>
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-toolbar-action-btn--compact"
@onclick="() => AddSignatureForReceiver(receiver)"
title="Signatur hinzufügen">
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-toolbar-action-btn--compact
@(_pendingReceiverForPlacement?.Id == receiver.Id ? "pdf-toolbar__btn--signature-change-active" : "")"
@onclick="() => ActivatePlacementForReceiver(receiver)"
title="Signaturfeld platzieren">
<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>
@@ -121,23 +125,11 @@
@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"
@onclick="ClearAllFieldsAsync"
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" />
@@ -161,11 +153,15 @@
</div>
@* Placement mode hint bar *@
@if (_placementMode)
@if (_pendingReceiverForPlacement is not null)
{
<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.
📌 Klicken Sie auf die Stelle im Dokument für <strong>@_pendingReceiverForPlacement.FullName</strong>.
&nbsp;<button @onclick="CancelPlacement"
style="background:none;border:none;color:white;cursor:pointer;font-size:0.75rem;text-decoration:underline;padding:0;">
Abbrechen
</button>
</div>
}
</div>
@@ -209,63 +205,13 @@
}
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 *@
@* PDF viewer — click capture active only in placement mode *@
<div class="pdf-editor-wrapper"
style="cursor: @(_pendingReceiverForPlacement is not null ? "crosshair" : "default");"
@onclick="OnPdfAreaClickAsync">
<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>
@@ -356,36 +302,80 @@
</DxPopup>
@code {
// ── Session query param — persists across SignalR reconnects ──
[SupplyParameterFromQuery(Name = "esid")]
public string? Esid { get; set; }
// ── Constants ──
// Signature field size in PDF points (fixed): 1.77" × 1.96" × 72 pt/inch
// Signature field size in PDF points (fixed): 1.77" × 1.96"
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;
// CssClass for DxPdfViewer — used by JS to locate page elements
const string ViewerCssClass = "sender-editor-pdf-viewer";
// Cache TTL for editor session (30 min of inactivity)
static readonly TimeSpan SessionTtl = TimeSpan.FromMinutes(30);
// ── State ──
DxPdfViewer? _pdfViewer;
byte[]? _pdfBytes;
bool _pdfLoaded = false;
string _fileName = string.Empty;
bool _pdfLoaded = false;
string _fileName = string.Empty;
string? _errorMessage;
bool _placementMode = false;
byte[]? _pdfBytes; // Current rendered PDF (original + placeholders burned in)
byte[]? _originalPdfBytes; // Pristine upload — never modified, used as base for redraw
List<SignatureFieldDraft> _signatureFields = [];
ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen"
List<ReceiverDraft> _receivers = [];
bool _receiverPopupVisible;
string _receiverDraftName = string.Empty;
string _receiverDraftEmail = string.Empty;
bool _receiverPopupVisible;
string _receiverDraftName = string.Empty;
string _receiverDraftEmail = string.Empty;
string _receiverDraftPhoneNumber = string.Empty;
string? _selectedReceiverEmailSuggestion;
string? _receiverPopupValidationMessage;
bool _isReceiverEmailSearchRunning;
bool _isReceiverEmailSearchRunning;
List<string> _receiverEmailSuggestions = [];
int _receiverEmailSearchVersion;
int _receiverEmailSearchVersion;
static readonly System.ComponentModel.DataAnnotations.EmailAddressAttribute ReceiverEmailValidator = new();
// ── Cache key helper ──
string SessionKey => $"sender-editor:{Esid}";
// ── Lifecycle ──
protected override void OnInitialized()
{
// If no session id exists yet, generate one and redirect so it sticks in the URL.
// This is the ONLY navigation that uses forceLoad; afterwards the page lives forever.
if (string.IsNullOrWhiteSpace(Esid))
{
var sid = Guid.NewGuid().ToString("N");
NavigationManager.NavigateTo($"/sender/editor?esid={sid}", forceLoad: false);
}
}
protected override void OnParametersSet()
{
// After the esid is set via query param, try to restore from cache.
if (!string.IsNullOrWhiteSpace(Esid)
&& MemoryCache.TryGetValue(SessionKey, out EditorSessionData? cached)
&& cached is not null)
{
_originalPdfBytes = cached.OriginalPdfBytes;
_signatureFields = cached.Fields;
_fileName = cached.FileName;
_pdfLoaded = _originalPdfBytes is { Length: > 0 };
_receivers = cached.Receivers;
// Redraw placeholders onto the original PDF
if (_pdfLoaded)
_pdfBytes = DrawPlaceholders(_originalPdfBytes!, _signatureFields);
}
}
// ── PDF upload ──
async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e)
{
@@ -401,17 +391,22 @@
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);
_originalPdfBytes = ms.ToArray();
_fileName = file.Name;
_pdfLoaded = true;
_signatureFields.Clear();
_pendingReceiverForPlacement = null;
// Rendered PDF starts as the clean original (no placeholders yet)
_pdfBytes = _originalPdfBytes;
PersistSession();
Logger.LogInformation("PDF loaded: {Name} ({Size} bytes)", _fileName, _originalPdfBytes.Length);
}
catch (Exception ex)
{
@@ -421,61 +416,101 @@
}
// ── Placement mode ──
void TogglePlacementMode() => _placementMode = !_placementMode;
void ClearAllFields()
void ActivatePlacementForReceiver(ReceiverDraft receiver)
{
_signatureFields.Clear();
_placementMode = false;
// Toggle: clicking the same receiver again cancels placement
_pendingReceiverForPlacement = _pendingReceiverForPlacement?.Id == receiver.Id
? null
: receiver;
}
void RemoveField(SignatureFieldDraft field) => _signatureFields.Remove(field);
void CancelPlacement() => _pendingReceiverForPlacement = null;
void Cancel() => NavigationManager.NavigateTo("/sender");
// ── Overlay click → add signature field ──
async Task OnOverlayClickAsync(MouseEventArgs e)
// ── PDF area click → place field ──
async Task OnPdfAreaClickAsync(MouseEventArgs e)
{
if (!_placementMode) return;
if (_pendingReceiverForPlacement is null) return;
if (!_pdfLoaded || _originalPdfBytes is null) return;
// Get overlay container bounds via JS
var coords = await JSRuntime.InvokeAsync<OverlayCoords>(
"envelopeEditor.getClickCoords", "pdf-editor-overlay",
e.ClientX, e.ClientY);
// Ask JS for the normalised click position within the rendered PDF page
var coords = await JSRuntime.InvokeAsync<NormalisedCoords?>(
"envelopeEditor.getClickCoordsOnPdfPage",
ViewerCssClass, e.ClientX, e.ClientY);
if (coords is null) return;
if (coords is null)
{
Logger.LogWarning("[SenderEditor] getClickCoordsOnPdfPage returned 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;
// Read page dimensions from the original PDF via PdfSharp
double pageWidthPt;
double pageHeightPt;
try
{
using var ms = new System.IO.MemoryStream(_originalPdfBytes);
var doc = PdfSharp.Pdf.IO.PdfReader.Open(ms, PdfSharp.Pdf.IO.PdfDocumentOpenMode.Import);
int pageIndex = Math.Max(0, Math.Min(coords.PageIndex, doc.PageCount - 1));
var page = doc.Pages[pageIndex];
pageWidthPt = page.Width.Point;
pageHeightPt = page.Height.Point;
}
catch (Exception ex)
{
Logger.LogError(ex, "[SenderEditor] Failed to read page dimensions from PDF");
return;
}
double xPt = coords.RelX * pxToPt;
double yPt = coords.RelY * pxToPt;
// Convert normalised [0,1] → PDF points; clamp so box stays inside page
double xPt = coords.NormX * pageWidthPt;
double yPt = coords.NormY * pageHeightPt;
// Active page: DxPdfViewer.ActivePageIndex is 0-based
int page = (_pdfViewer?.ActivePageIndex ?? 0) + 1;
xPt = Math.Max(0, Math.Min(xPt, pageWidthPt - SigWidthPt));
yPt = Math.Max(0, Math.Min(yPt, pageHeightPt - SigHeightPt));
// Display position (px on overlay) — keep in px for CSS
double displayX = coords.RelX;
double displayY = coords.RelY;
int page1Based = coords.PageIndex + 1;
// 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: xPt,
YPt: yPt,
Page: page1Based,
ReceiverName: _pendingReceiverForPlacement.FullName);
var field = new SignatureFieldDraft(xPt, yPt, page, displayX, displayY);
_signatureFields.Add(field);
_pendingReceiverForPlacement = null;
// Burn all placeholders onto the original PDF and update the viewer
_pdfBytes = DrawPlaceholders(_originalPdfBytes, _signatureFields);
PersistSession();
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;
"[SenderEditor] Field added: Page={Page} X={X:F1}pt Y={Y:F1}pt Receiver={Receiver}",
page1Based, xPt, yPt, field.ReceiverName);
}
// ── Remove a single field ──
async Task RemoveFieldAsync(SignatureFieldDraft field)
{
_signatureFields.Remove(field);
_pdfBytes = _originalPdfBytes is not null
? DrawPlaceholders(_originalPdfBytes, _signatureFields)
: _pdfBytes;
PersistSession();
await Task.CompletedTask;
}
// ── Clear all fields ──
async Task ClearAllFieldsAsync()
{
_signatureFields.Clear();
_pendingReceiverForPlacement = null;
_pdfBytes = _originalPdfBytes;
PersistSession();
await Task.CompletedTask;
}
void Cancel() => NavigationManager.NavigateTo("/sender");
// ── Save ──
async Task SaveAsync()
{
@@ -489,20 +524,97 @@
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)");
$"[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) Receiver={f.ReceiverName}");
}
await JSRuntime.InvokeVoidAsync("console.log",
$"[SenderEditor] Total fields: {_signatureFields.Count}");
}
// ── Cache persistence ──
void PersistSession()
{
if (string.IsNullOrWhiteSpace(Esid)) return;
var data = new EditorSessionData(
OriginalPdfBytes: _originalPdfBytes ?? [],
Fields: [.. _signatureFields],
FileName: _fileName,
Receivers: [.. _receivers]);
MemoryCache.Set(SessionKey, data, SessionTtl);
}
// ── PdfSharp: burn all placeholder boxes onto the original PDF ──
static byte[] DrawPlaceholders(byte[] originalPdf, IReadOnlyList<SignatureFieldDraft> fields)
{
if (fields.Count == 0) return originalPdf;
using var inputMs = new System.IO.MemoryStream(originalPdf);
using var outputMs = new System.IO.MemoryStream();
var document = PdfSharp.Pdf.IO.PdfReader.Open(
inputMs, PdfSharp.Pdf.IO.PdfDocumentOpenMode.Modify);
// Visual style — same palette as the receiver-side placeholder
var fillBrush = new PdfSharp.Drawing.XSolidBrush(PdfSharp.Drawing.XColor.FromArgb( 40, 60, 80, 160));
var borderPen = new PdfSharp.Drawing.XPen(PdfSharp.Drawing.XColor.FromArgb(200, 60, 80, 200), 1.5);
var textBrush = new PdfSharp.Drawing.XSolidBrush(PdfSharp.Drawing.XColor.FromArgb(200, 40, 60, 140));
var nameBrush = new PdfSharp.Drawing.XSolidBrush(PdfSharp.Drawing.XColor.FromArgb(255, 30, 30, 100));
var fontLabel = new PdfSharp.Drawing.XFont("Arial", 9, PdfSharp.Drawing.XFontStyleEx.Bold);
var fontName = new PdfSharp.Drawing.XFont("Arial", 7, PdfSharp.Drawing.XFontStyleEx.Regular);
var fmtCenter = new PdfSharp.Drawing.XStringFormat
{
Alignment = PdfSharp.Drawing.XStringAlignment.Center,
LineAlignment = PdfSharp.Drawing.XLineAlignment.Center,
};
var fmtBottomCenter = new PdfSharp.Drawing.XStringFormat
{
Alignment = PdfSharp.Drawing.XStringAlignment.Center,
LineAlignment = PdfSharp.Drawing.XLineAlignment.Far,
};
foreach (var field in fields)
{
int pageIndex = field.Page - 1;
if (pageIndex < 0 || pageIndex >= document.PageCount) continue;
var page = document.Pages[pageIndex];
using var gfx = PdfSharp.Drawing.XGraphics.FromPdfPage(page);
var rect = new PdfSharp.Drawing.XRect(field.XPt, field.YPt, SigWidthPt, SigHeightPt);
gfx.DrawRectangle(fillBrush, rect);
gfx.DrawRectangle(borderPen, rect);
// "UNTERSCHRIFT" label centred in upper two-thirds
var labelRect = new PdfSharp.Drawing.XRect(
field.XPt, field.YPt, SigWidthPt, SigHeightPt * 0.65);
gfx.DrawString("UNTERSCHRIFT", fontLabel, textBrush, labelRect, fmtCenter);
// Receiver name centred in lower third
var nameRect = new PdfSharp.Drawing.XRect(
field.XPt + 4, field.YPt + SigHeightPt * 0.68,
SigWidthPt - 8, SigHeightPt * 0.30);
var displayName = field.ReceiverName.Length > 22
? field.ReceiverName[..19] + "..."
: field.ReceiverName;
gfx.DrawString(displayName, fontName, nameBrush, nameRect, fmtCenter);
}
document.Save(outputMs);
return outputMs.ToArray();
}
// ── Receiver popup ──
void OpenAddReceiverPopup()
{
_receiverDraftName = string.Empty;
_receiverDraftEmail = string.Empty;
_receiverDraftName = string.Empty;
_receiverDraftEmail = string.Empty;
_receiverDraftPhoneNumber = string.Empty;
_selectedReceiverEmailSuggestion = null;
_receiverPopupValidationMessage = null;
_receiverPopupValidationMessage = null;
_receiverEmailSuggestions.Clear();
_receiverPopupVisible = true;
}
@@ -510,9 +622,9 @@
void CloseAddReceiverPopup()
{
_receiverPopupVisible = false;
_receiverPopupValidationMessage = null;
_receiverPopupValidationMessage = null;
_selectedReceiverEmailSuggestion = null;
_isReceiverEmailSearchRunning = false;
_isReceiverEmailSearchRunning = false;
}
void OnReceiverNameChanged(string? value)
@@ -531,34 +643,32 @@
{
if (_receiverEmailSuggestions.Count == 0)
{
if (e.Key == "Escape")
_selectedReceiverEmailSuggestion = null;
if (e.Key == "Escape") _selectedReceiverEmailSuggestion = null;
return;
}
var currentIndex = _selectedReceiverEmailSuggestion is null
? -1
: _receiverEmailSuggestions.FindIndex(email => string.Equals(email, _selectedReceiverEmailSuggestion, StringComparison.OrdinalIgnoreCase));
: _receiverEmailSuggestions.FindIndex(em =>
string.Equals(em, _selectedReceiverEmailSuggestion, StringComparison.OrdinalIgnoreCase));
if (e.Key == "ArrowDown")
{
var nextIndex = currentIndex < _receiverEmailSuggestions.Count - 1 ? currentIndex + 1 : 0;
SelectReceiverEmailSuggestion(_receiverEmailSuggestions[nextIndex]);
var next = currentIndex < _receiverEmailSuggestions.Count - 1 ? currentIndex + 1 : 0;
SelectReceiverEmailSuggestion(_receiverEmailSuggestions[next]);
}
else if (e.Key == "ArrowUp")
{
var nextIndex = currentIndex > 0 ? currentIndex - 1 : _receiverEmailSuggestions.Count - 1;
SelectReceiverEmailSuggestion(_receiverEmailSuggestions[nextIndex]);
var next = currentIndex > 0 ? currentIndex - 1 : _receiverEmailSuggestions.Count - 1;
SelectReceiverEmailSuggestion(_receiverEmailSuggestions[next]);
}
else if (e.Key == "Enter")
{
var selectedValue = currentIndex >= 0 && currentIndex < _receiverEmailSuggestions.Count
var sel = currentIndex >= 0 && currentIndex < _receiverEmailSuggestions.Count
? _receiverEmailSuggestions[currentIndex]
: _receiverEmailSuggestions.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(selectedValue))
await OnReceiverEmailSuggestionCommittedAsync(selectedValue);
if (!string.IsNullOrWhiteSpace(sel))
await OnReceiverEmailSuggestionCommittedAsync(sel);
}
else if (e.Key == "Escape")
{
@@ -569,20 +679,12 @@
void SelectReceiverEmailSuggestion(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return;
if (string.IsNullOrWhiteSpace(value)) return;
_selectedReceiverEmailSuggestion = value.Trim();
_receiverDraftEmail = _selectedReceiverEmailSuggestion;
_receiverPopupValidationMessage = null;
}
Task OnReceiverEmailSuggestionSelectedAsync(string? value)
{
SelectReceiverEmailSuggestion(value);
return Task.CompletedTask;
}
Task OnReceiverEmailSuggestionCommittedAsync(string? value)
{
SelectReceiverEmailSuggestion(value);
@@ -594,7 +696,7 @@
{
_receiverDraftEmail = value?.Trim() ?? string.Empty;
_selectedReceiverEmailSuggestion = _receiverDraftEmail;
_receiverPopupValidationMessage = null;
_receiverPopupValidationMessage = null;
var searchVersion = ++_receiverEmailSearchVersion;
@@ -602,7 +704,7 @@
{
_receiverEmailSuggestions.Clear();
_selectedReceiverEmailSuggestion = null;
_isReceiverEmailSearchRunning = false;
_isReceiverEmailSearchRunning = false;
return;
}
@@ -611,19 +713,17 @@
try
{
var results = await ReceiverPageDataService.SearchReceiverEMailsAsync(_receiverDraftEmail);
if (searchVersion != _receiverEmailSearchVersion)
return;
if (searchVersion != _receiverEmailSearchVersion) return;
_receiverEmailSuggestions = results
.Where(email => !string.IsNullOrWhiteSpace(email))
.Where(em => !string.IsNullOrWhiteSpace(em))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(email => email)
.OrderBy(em => em)
.Take(12)
.ToList();
_selectedReceiverEmailSuggestion = _receiverEmailSuggestions.FirstOrDefault(email =>
string.Equals(email, _receiverDraftEmail, StringComparison.OrdinalIgnoreCase));
_selectedReceiverEmailSuggestion = _receiverEmailSuggestions.FirstOrDefault(em =>
string.Equals(em, _receiverDraftEmail, StringComparison.OrdinalIgnoreCase));
}
catch (Exception ex)
{
@@ -643,8 +743,8 @@
Task SaveReceiverAsync()
{
var fullName = _receiverDraftName.Trim();
var email = _receiverDraftEmail.Trim();
var fullName = _receiverDraftName.Trim();
var email = _receiverDraftEmail.Trim();
var phoneNumber = _receiverDraftPhoneNumber.Trim();
if (string.IsNullOrWhiteSpace(fullName))
@@ -652,50 +752,38 @@
_receiverPopupValidationMessage = "Bitte geben Sie einen Vor- und Nachnamen ein.";
return Task.CompletedTask;
}
if (string.IsNullOrWhiteSpace(email))
{
_receiverPopupValidationMessage = "Bitte geben Sie eine E-Mail-Adresse ein.";
return Task.CompletedTask;
}
if (!ReceiverEmailValidator.IsValid(email))
{
_receiverPopupValidationMessage = "Bitte geben Sie eine gültige E-Mail-Adresse ein.";
return Task.CompletedTask;
}
if (_receivers.Any(receiver => string.Equals(receiver.Email, email, StringComparison.OrdinalIgnoreCase)))
if (_receivers.Any(r => string.Equals(r.Email, email, StringComparison.OrdinalIgnoreCase)))
{
_receiverPopupValidationMessage = "Diese E-Mail-Adresse wurde bereits hinzugefügt.";
return Task.CompletedTask;
}
_receivers.Add(new ReceiverDraft(Guid.NewGuid(), fullName, email, phoneNumber));
PersistSession();
CloseAddReceiverPopup();
return Task.CompletedTask;
}
void AddSignatureForReceiver(ReceiverDraft receiver)
{
Logger.LogInformation("Signature placement requested for receiver {Email}", receiver.Email);
}
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 SignatureFieldDraft(double XPt, double YPt, int Page, string ReceiverName);
record OverlayCoords(double RelX, double RelY, double ContainerW, double ContainerH);
record NormalisedCoords(double NormX, double NormY, int PageIndex);
record ReceiverDraft(Guid Id, string FullName, string Email, string PhoneNumber);
record EditorSessionData(
byte[] OriginalPdfBytes,
List<SignatureFieldDraft> Fields,
string FileName,
List<ReceiverDraft> Receivers);
}

View File

@@ -52,8 +52,8 @@
}
.pdf-editor-wrapper {
position: relative;
min-height: 100%;
width: 100%;
height: 100%;
}
.sender-editor-pdf-viewer {
@@ -61,6 +61,14 @@
height: 100%;
}
.sender-editor-pdf-viewer .dxbl-toolbar {
justify-content: center;
}
.sender-editor-pdf-viewer .dxbl-toolbar-left {
margin-inline: auto;
}
.sender-editor-pdf-viewer .dxbrv-document-surface {
display: flex;
flex-direction: column;

View File

@@ -1,92 +1,95 @@
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 }}
* Returns the click position normalised to [0,1] relative to the rendered PDF page
* element inside DxPdfViewer (or DxReportViewer as fallback).
*
* Normalising means the result is independent of zoom level: no matter how much the
* user has zoomed in/out, the same physical spot on the PDF will always yield the same
* normalised value. C# multiplies by the page's point dimensions to get PDF points.
*
* @param {string} viewerCssClass - CssClass set on DxPdfViewer (e.g. "sender-editor-pdf-viewer")
* @param {number} clientX - MouseEvent.clientX from Blazor
* @param {number} clientY - MouseEvent.clientY from Blazor
* @returns {{ normX, normY, pageIndex } | null}
* normX / normY : 0..1 fraction within the page element
* pageIndex : 0-based index of the page the click landed on (-1 if not found)
*/
getClickCoords: function (overlayId, clientX, clientY) {
const el = document.getElementById(overlayId);
if (!el) return null;
getClickCoordsOnPdfPage: function (viewerCssClass, clientX, clientY) {
// Find the viewer root element
const viewer = document.querySelector('.' + viewerCssClass);
if (!viewer) {
console.warn('[envelopeEditor] viewer not found for class:', viewerCssClass);
return null;
}
// --- Candidate page elements (ordered by preference) ---
// DxPdfViewer renders individual pages as .dxbl-pdfv-page elements.
// DxReportViewer uses .dxbrv-report-preview-content-img as fallback.
const pageSelectors = [
'.dxbl-pdfv-page',
'.dxbrv-report-preview-page',
'.dxbrv-report-preview-content-img',
];
let allPages = [];
for (const sel of pageSelectors) {
const found = Array.from(viewer.querySelectorAll(sel));
if (found.length > 0) {
allPages = found;
break;
}
}
if (allPages.length === 0) {
console.warn('[envelopeEditor] no page elements found inside viewer');
return null;
}
// --- Find which page the click landed on ---
// Walk through all pages; pick the one whose bounding rect contains the click.
// If none contains it exactly, fall back to the page closest vertically.
let targetPage = null;
let targetIndex = -1;
let minDist = Infinity;
for (let i = 0; i < allPages.length; i++) {
const rect = allPages[i].getBoundingClientRect();
// Exact hit
if (clientX >= rect.left && clientX <= rect.right &&
clientY >= rect.top && clientY <= rect.bottom) {
targetPage = allPages[i];
targetIndex = i;
break;
}
// Track closest page (vertical centre distance) as fallback
const cy = rect.top + rect.height / 2;
const dist = Math.abs(clientY - cy);
if (dist < minDist) {
minDist = dist;
targetPage = allPages[i];
targetIndex = i;
}
}
if (!targetPage) return null;
const pageRect = targetPage.getBoundingClientRect();
// Clamp click inside page boundaries before normalising
const clampedX = Math.max(pageRect.left, Math.min(clientX, pageRect.right));
const clampedY = Math.max(pageRect.top, Math.min(clientY, pageRect.bottom));
const normX = (clampedX - pageRect.left) / pageRect.width;
const normY = (clampedY - pageRect.top) / pageRect.height;
const rect = el.getBoundingClientRect();
return {
relX: clientX - rect.left,
relY: clientY - rect.top,
containerW: rect.width,
containerH: rect.height
normX: normX,
normY: normY,
pageIndex: targetIndex
};
},
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);
}
};