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