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.Client.Services
|
||||||
@using EnvelopeGenerator.Server.Services
|
@using EnvelopeGenerator.Server.Services
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using Microsoft.Extensions.Caching.Memory
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject AppVersionService AppVersion
|
@inject AppVersionService AppVersion
|
||||||
@inject ILogger<EnvelopeSenderEditorPage> Logger
|
@inject ILogger<EnvelopeSenderEditorPage> Logger
|
||||||
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
|
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
|
||||||
|
@inject IMemoryCache MemoryCache
|
||||||
|
|
||||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
<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" />
|
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
|
||||||
<script src="@AppVersion.GetVersionedUrl("js/envelope-editor.js")"></script>
|
<script src="@AppVersion.GetVersionedUrl("js/envelope-editor.js")"></script>
|
||||||
|
|
||||||
@@ -81,9 +84,10 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-toolbar-action-btn--compact"
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-toolbar-action-btn--compact
|
||||||
@onclick="() => AddSignatureForReceiver(receiver)"
|
@(_pendingReceiverForPlacement?.Id == receiver.Id ? "pdf-toolbar__btn--signature-change-active" : "")"
|
||||||
title="Signatur hinzufügen">
|
@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">
|
<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" />
|
<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>
|
</svg>
|
||||||
@@ -121,23 +125,11 @@
|
|||||||
|
|
||||||
@if (_pdfLoaded)
|
@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 *@
|
@* Clear all fields *@
|
||||||
@if (_signatureFields.Count > 0)
|
@if (_signatureFields.Count > 0)
|
||||||
{
|
{
|
||||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
|
||||||
@onclick="ClearAllFields"
|
@onclick="ClearAllFieldsAsync"
|
||||||
title="Alle Signaturfelder entfernen">
|
title="Alle Signaturfelder entfernen">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
<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 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>
|
</div>
|
||||||
|
|
||||||
@* Placement mode hint bar *@
|
@* Placement mode hint bar *@
|
||||||
@if (_placementMode)
|
@if (_pendingReceiverForPlacement is not null)
|
||||||
{
|
{
|
||||||
<div style="background: #4F46E5; color: white; font-size: 0.75rem; font-weight: 500;
|
<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;">
|
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>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -209,63 +205,13 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@* PDF viewer + overlay wrapper *@
|
@* PDF viewer — click capture active only in placement mode *@
|
||||||
<div id="pdf-editor-wrapper" class="pdf-editor-wrapper"
|
<div class="pdf-editor-wrapper"
|
||||||
style="position: relative; width: 100%; height: 100%; overflow: auto;">
|
style="cursor: @(_pendingReceiverForPlacement is not null ? "crosshair" : "default");"
|
||||||
|
@onclick="OnPdfAreaClickAsync">
|
||||||
@* DxPdfViewer — zoom fixed to 1.0 for reliable coordinate mapping *@
|
|
||||||
<DxPdfViewer @ref="_pdfViewer"
|
<DxPdfViewer @ref="_pdfViewer"
|
||||||
DocumentContent="@_pdfBytes"
|
DocumentContent="@_pdfBytes"
|
||||||
ZoomLevel="1.0"
|
|
||||||
IsSinglePagePreview="false"
|
|
||||||
CssClass="sender-editor-pdf-viewer" />
|
CssClass="sender-editor-pdf-viewer" />
|
||||||
|
|
||||||
@* Transparent overlay for click capture (active only in placement mode) *@
|
|
||||||
<div id="pdf-editor-overlay"
|
|
||||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
|
||||||
z-index: 20;
|
|
||||||
pointer-events: @(_placementMode ? "auto" : "none");
|
|
||||||
cursor: @(_placementMode ? "crosshair" : "default");"
|
|
||||||
@onclick="OnOverlayClickAsync">
|
|
||||||
|
|
||||||
@* Render placed signature field placeholders *@
|
|
||||||
@foreach (var field in _signatureFields)
|
|
||||||
{
|
|
||||||
var f = field; // capture for lambda
|
|
||||||
<div style="position: absolute;
|
|
||||||
left: @(f.DisplayX.ToString("F1", System.Globalization.CultureInfo.InvariantCulture))px;
|
|
||||||
top: @(f.DisplayY.ToString("F1", System.Globalization.CultureInfo.InvariantCulture))px;
|
|
||||||
width: @(SigDisplayW)px;
|
|
||||||
height: @(SigDisplayH)px;
|
|
||||||
border: 2px dashed #4F46E5;
|
|
||||||
background: rgba(79,70,229,0.10);
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 2px;
|
|
||||||
pointer-events: auto;
|
|
||||||
cursor: default;"
|
|
||||||
@onclick:stopPropagation="true">
|
|
||||||
<span style="font-size: 0.6rem; font-weight: 700; color: #4F46E5;
|
|
||||||
letter-spacing: 0.05em; text-transform: uppercase;">
|
|
||||||
Unterschrift
|
|
||||||
</span>
|
|
||||||
<span style="font-size: 0.55rem; color: #6d28d9; opacity: 0.8;">
|
|
||||||
S.@f.Page
|
|
||||||
</span>
|
|
||||||
<button @onclick="() => RemoveField(f)"
|
|
||||||
style="position: absolute; top: 2px; right: 4px;
|
|
||||||
background: none; border: none; cursor: pointer;
|
|
||||||
color: #6d28d9; font-size: 0.75rem; line-height: 1;
|
|
||||||
padding: 0; opacity: 0.7;"
|
|
||||||
title="Entfernen">
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -356,36 +302,80 @@
|
|||||||
</DxPopup>
|
</DxPopup>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
// ── Session query param — persists across SignalR reconnects ──
|
||||||
|
[SupplyParameterFromQuery(Name = "esid")]
|
||||||
|
public string? Esid { get; set; }
|
||||||
|
|
||||||
// ── Constants ──
|
// ── 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 SigWidthPt = 1.77 * 72; // 127.44 pt
|
||||||
const double SigHeightPt = 1.96 * 72; // 141.12 pt
|
const double SigHeightPt = 1.96 * 72; // 141.12 pt
|
||||||
|
|
||||||
// Display size of the overlay placeholder (pixels at zoom=1.0).
|
// CssClass for DxPdfViewer — used by JS to locate page elements
|
||||||
// At zoom=1.0, 1 CSS px ≈ 1 pt in the DxPdfViewer render.
|
const string ViewerCssClass = "sender-editor-pdf-viewer";
|
||||||
const double SigDisplayW = SigWidthPt;
|
|
||||||
const double SigDisplayH = SigHeightPt;
|
// Cache TTL for editor session (30 min of inactivity)
|
||||||
|
static readonly TimeSpan SessionTtl = TimeSpan.FromMinutes(30);
|
||||||
|
|
||||||
// ── State ──
|
// ── State ──
|
||||||
DxPdfViewer? _pdfViewer;
|
DxPdfViewer? _pdfViewer;
|
||||||
byte[]? _pdfBytes;
|
bool _pdfLoaded = false;
|
||||||
bool _pdfLoaded = false;
|
string _fileName = string.Empty;
|
||||||
string _fileName = string.Empty;
|
|
||||||
string? _errorMessage;
|
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 = [];
|
List<SignatureFieldDraft> _signatureFields = [];
|
||||||
|
ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen"
|
||||||
|
|
||||||
List<ReceiverDraft> _receivers = [];
|
List<ReceiverDraft> _receivers = [];
|
||||||
bool _receiverPopupVisible;
|
bool _receiverPopupVisible;
|
||||||
string _receiverDraftName = string.Empty;
|
string _receiverDraftName = string.Empty;
|
||||||
string _receiverDraftEmail = string.Empty;
|
string _receiverDraftEmail = string.Empty;
|
||||||
string _receiverDraftPhoneNumber = string.Empty;
|
string _receiverDraftPhoneNumber = string.Empty;
|
||||||
string? _selectedReceiverEmailSuggestion;
|
string? _selectedReceiverEmailSuggestion;
|
||||||
string? _receiverPopupValidationMessage;
|
string? _receiverPopupValidationMessage;
|
||||||
bool _isReceiverEmailSearchRunning;
|
bool _isReceiverEmailSearchRunning;
|
||||||
List<string> _receiverEmailSuggestions = [];
|
List<string> _receiverEmailSuggestions = [];
|
||||||
int _receiverEmailSearchVersion;
|
int _receiverEmailSearchVersion;
|
||||||
|
|
||||||
static readonly System.ComponentModel.DataAnnotations.EmailAddressAttribute ReceiverEmailValidator = new();
|
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 ──
|
// ── PDF upload ──
|
||||||
async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e)
|
async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -401,17 +391,22 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Max 50 MB
|
|
||||||
const long maxBytes = 50 * 1024 * 1024;
|
const long maxBytes = 50 * 1024 * 1024;
|
||||||
using var ms = new System.IO.MemoryStream();
|
using var ms = new System.IO.MemoryStream();
|
||||||
await file.OpenReadStream(maxBytes).CopyToAsync(ms);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -421,61 +416,101 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Placement mode ──
|
// ── Placement mode ──
|
||||||
void TogglePlacementMode() => _placementMode = !_placementMode;
|
void ActivatePlacementForReceiver(ReceiverDraft receiver)
|
||||||
|
|
||||||
void ClearAllFields()
|
|
||||||
{
|
{
|
||||||
_signatureFields.Clear();
|
// Toggle: clicking the same receiver again cancels placement
|
||||||
_placementMode = false;
|
_pendingReceiverForPlacement = _pendingReceiverForPlacement?.Id == receiver.Id
|
||||||
|
? null
|
||||||
|
: receiver;
|
||||||
}
|
}
|
||||||
|
|
||||||
void RemoveField(SignatureFieldDraft field) => _signatureFields.Remove(field);
|
void CancelPlacement() => _pendingReceiverForPlacement = null;
|
||||||
|
|
||||||
void Cancel() => NavigationManager.NavigateTo("/sender");
|
// ── PDF area click → place field ──
|
||||||
|
async Task OnPdfAreaClickAsync(MouseEventArgs e)
|
||||||
// ── Overlay click → add signature field ──
|
|
||||||
async Task OnOverlayClickAsync(MouseEventArgs e)
|
|
||||||
{
|
{
|
||||||
if (!_placementMode) return;
|
if (_pendingReceiverForPlacement is null) return;
|
||||||
|
if (!_pdfLoaded || _originalPdfBytes is null) return;
|
||||||
|
|
||||||
// Get overlay container bounds via JS
|
// Ask JS for the normalised click position within the rendered PDF page
|
||||||
var coords = await JSRuntime.InvokeAsync<OverlayCoords>(
|
var coords = await JSRuntime.InvokeAsync<NormalisedCoords?>(
|
||||||
"envelopeEditor.getClickCoords", "pdf-editor-overlay",
|
"envelopeEditor.getClickCoordsOnPdfPage",
|
||||||
e.ClientX, e.ClientY);
|
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.
|
// Read page dimensions from the original PDF via PdfSharp
|
||||||
// DxPdfViewer renders at 96 dpi by default; PDF points = 72 dpi.
|
double pageWidthPt;
|
||||||
// Scale factor: 96/72 = 1.333 → px / 1.333 = pt
|
double pageHeightPt;
|
||||||
const double pxToPt = 72.0 / 96.0;
|
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;
|
// Convert normalised [0,1] → PDF points; clamp so box stays inside page
|
||||||
double yPt = coords.RelY * pxToPt;
|
double xPt = coords.NormX * pageWidthPt;
|
||||||
|
double yPt = coords.NormY * pageHeightPt;
|
||||||
|
|
||||||
// Active page: DxPdfViewer.ActivePageIndex is 0-based
|
xPt = Math.Max(0, Math.Min(xPt, pageWidthPt - SigWidthPt));
|
||||||
int page = (_pdfViewer?.ActivePageIndex ?? 0) + 1;
|
yPt = Math.Max(0, Math.Min(yPt, pageHeightPt - SigHeightPt));
|
||||||
|
|
||||||
// Display position (px on overlay) — keep in px for CSS
|
int page1Based = coords.PageIndex + 1;
|
||||||
double displayX = coords.RelX;
|
|
||||||
double displayY = coords.RelY;
|
|
||||||
|
|
||||||
// Prevent placing outside bounds
|
var field = new SignatureFieldDraft(
|
||||||
if (displayX < 0 || displayY < 0) return;
|
XPt: xPt,
|
||||||
if (displayX + SigDisplayW > coords.ContainerW) displayX = coords.ContainerW - SigDisplayW;
|
YPt: yPt,
|
||||||
if (displayY + SigDisplayH > coords.ContainerH) displayY = coords.ContainerH - SigDisplayH;
|
Page: page1Based,
|
||||||
|
ReceiverName: _pendingReceiverForPlacement.FullName);
|
||||||
|
|
||||||
var field = new SignatureFieldDraft(xPt, yPt, page, displayX, displayY);
|
|
||||||
_signatureFields.Add(field);
|
_signatureFields.Add(field);
|
||||||
|
_pendingReceiverForPlacement = null;
|
||||||
|
|
||||||
|
// Burn all placeholders onto the original PDF and update the viewer
|
||||||
|
_pdfBytes = DrawPlaceholders(_originalPdfBytes, _signatureFields);
|
||||||
|
PersistSession();
|
||||||
|
|
||||||
Logger.LogInformation(
|
Logger.LogInformation(
|
||||||
"Signature field added: Page={Page} X={X:F1}pt Y={Y:F1}pt",
|
"[SenderEditor] Field added: Page={Page} X={X:F1}pt Y={Y:F1}pt Receiver={Receiver}",
|
||||||
page, xPt, yPt);
|
page1Based, xPt, yPt, field.ReceiverName);
|
||||||
|
|
||||||
// Exit placement mode after one click (user can re-click button for next)
|
|
||||||
_placementMode = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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 ──
|
// ── Save ──
|
||||||
async Task SaveAsync()
|
async Task SaveAsync()
|
||||||
{
|
{
|
||||||
@@ -489,20 +524,97 @@
|
|||||||
foreach (var f in _signatureFields)
|
foreach (var f in _signatureFields)
|
||||||
{
|
{
|
||||||
await JSRuntime.InvokeVoidAsync("console.log",
|
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",
|
await JSRuntime.InvokeVoidAsync("console.log",
|
||||||
$"[SenderEditor] Total fields: {_signatureFields.Count}");
|
$"[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()
|
void OpenAddReceiverPopup()
|
||||||
{
|
{
|
||||||
_receiverDraftName = string.Empty;
|
_receiverDraftName = string.Empty;
|
||||||
_receiverDraftEmail = string.Empty;
|
_receiverDraftEmail = string.Empty;
|
||||||
_receiverDraftPhoneNumber = string.Empty;
|
_receiverDraftPhoneNumber = string.Empty;
|
||||||
_selectedReceiverEmailSuggestion = null;
|
_selectedReceiverEmailSuggestion = null;
|
||||||
_receiverPopupValidationMessage = null;
|
_receiverPopupValidationMessage = null;
|
||||||
_receiverEmailSuggestions.Clear();
|
_receiverEmailSuggestions.Clear();
|
||||||
_receiverPopupVisible = true;
|
_receiverPopupVisible = true;
|
||||||
}
|
}
|
||||||
@@ -510,9 +622,9 @@
|
|||||||
void CloseAddReceiverPopup()
|
void CloseAddReceiverPopup()
|
||||||
{
|
{
|
||||||
_receiverPopupVisible = false;
|
_receiverPopupVisible = false;
|
||||||
_receiverPopupValidationMessage = null;
|
_receiverPopupValidationMessage = null;
|
||||||
_selectedReceiverEmailSuggestion = null;
|
_selectedReceiverEmailSuggestion = null;
|
||||||
_isReceiverEmailSearchRunning = false;
|
_isReceiverEmailSearchRunning = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void OnReceiverNameChanged(string? value)
|
void OnReceiverNameChanged(string? value)
|
||||||
@@ -531,34 +643,32 @@
|
|||||||
{
|
{
|
||||||
if (_receiverEmailSuggestions.Count == 0)
|
if (_receiverEmailSuggestions.Count == 0)
|
||||||
{
|
{
|
||||||
if (e.Key == "Escape")
|
if (e.Key == "Escape") _selectedReceiverEmailSuggestion = null;
|
||||||
_selectedReceiverEmailSuggestion = null;
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentIndex = _selectedReceiverEmailSuggestion is null
|
var currentIndex = _selectedReceiverEmailSuggestion is null
|
||||||
? -1
|
? -1
|
||||||
: _receiverEmailSuggestions.FindIndex(email => string.Equals(email, _selectedReceiverEmailSuggestion, StringComparison.OrdinalIgnoreCase));
|
: _receiverEmailSuggestions.FindIndex(em =>
|
||||||
|
string.Equals(em, _selectedReceiverEmailSuggestion, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
if (e.Key == "ArrowDown")
|
if (e.Key == "ArrowDown")
|
||||||
{
|
{
|
||||||
var nextIndex = currentIndex < _receiverEmailSuggestions.Count - 1 ? currentIndex + 1 : 0;
|
var next = currentIndex < _receiverEmailSuggestions.Count - 1 ? currentIndex + 1 : 0;
|
||||||
SelectReceiverEmailSuggestion(_receiverEmailSuggestions[nextIndex]);
|
SelectReceiverEmailSuggestion(_receiverEmailSuggestions[next]);
|
||||||
}
|
}
|
||||||
else if (e.Key == "ArrowUp")
|
else if (e.Key == "ArrowUp")
|
||||||
{
|
{
|
||||||
var nextIndex = currentIndex > 0 ? currentIndex - 1 : _receiverEmailSuggestions.Count - 1;
|
var next = currentIndex > 0 ? currentIndex - 1 : _receiverEmailSuggestions.Count - 1;
|
||||||
SelectReceiverEmailSuggestion(_receiverEmailSuggestions[nextIndex]);
|
SelectReceiverEmailSuggestion(_receiverEmailSuggestions[next]);
|
||||||
}
|
}
|
||||||
else if (e.Key == "Enter")
|
else if (e.Key == "Enter")
|
||||||
{
|
{
|
||||||
var selectedValue = currentIndex >= 0 && currentIndex < _receiverEmailSuggestions.Count
|
var sel = currentIndex >= 0 && currentIndex < _receiverEmailSuggestions.Count
|
||||||
? _receiverEmailSuggestions[currentIndex]
|
? _receiverEmailSuggestions[currentIndex]
|
||||||
: _receiverEmailSuggestions.FirstOrDefault();
|
: _receiverEmailSuggestions.FirstOrDefault();
|
||||||
|
if (!string.IsNullOrWhiteSpace(sel))
|
||||||
if (!string.IsNullOrWhiteSpace(selectedValue))
|
await OnReceiverEmailSuggestionCommittedAsync(sel);
|
||||||
await OnReceiverEmailSuggestionCommittedAsync(selectedValue);
|
|
||||||
}
|
}
|
||||||
else if (e.Key == "Escape")
|
else if (e.Key == "Escape")
|
||||||
{
|
{
|
||||||
@@ -569,20 +679,12 @@
|
|||||||
|
|
||||||
void SelectReceiverEmailSuggestion(string? value)
|
void SelectReceiverEmailSuggestion(string? value)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
if (string.IsNullOrWhiteSpace(value)) return;
|
||||||
return;
|
|
||||||
|
|
||||||
_selectedReceiverEmailSuggestion = value.Trim();
|
_selectedReceiverEmailSuggestion = value.Trim();
|
||||||
_receiverDraftEmail = _selectedReceiverEmailSuggestion;
|
_receiverDraftEmail = _selectedReceiverEmailSuggestion;
|
||||||
_receiverPopupValidationMessage = null;
|
_receiverPopupValidationMessage = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Task OnReceiverEmailSuggestionSelectedAsync(string? value)
|
|
||||||
{
|
|
||||||
SelectReceiverEmailSuggestion(value);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
Task OnReceiverEmailSuggestionCommittedAsync(string? value)
|
Task OnReceiverEmailSuggestionCommittedAsync(string? value)
|
||||||
{
|
{
|
||||||
SelectReceiverEmailSuggestion(value);
|
SelectReceiverEmailSuggestion(value);
|
||||||
@@ -594,7 +696,7 @@
|
|||||||
{
|
{
|
||||||
_receiverDraftEmail = value?.Trim() ?? string.Empty;
|
_receiverDraftEmail = value?.Trim() ?? string.Empty;
|
||||||
_selectedReceiverEmailSuggestion = _receiverDraftEmail;
|
_selectedReceiverEmailSuggestion = _receiverDraftEmail;
|
||||||
_receiverPopupValidationMessage = null;
|
_receiverPopupValidationMessage = null;
|
||||||
|
|
||||||
var searchVersion = ++_receiverEmailSearchVersion;
|
var searchVersion = ++_receiverEmailSearchVersion;
|
||||||
|
|
||||||
@@ -602,7 +704,7 @@
|
|||||||
{
|
{
|
||||||
_receiverEmailSuggestions.Clear();
|
_receiverEmailSuggestions.Clear();
|
||||||
_selectedReceiverEmailSuggestion = null;
|
_selectedReceiverEmailSuggestion = null;
|
||||||
_isReceiverEmailSearchRunning = false;
|
_isReceiverEmailSearchRunning = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,19 +713,17 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var results = await ReceiverPageDataService.SearchReceiverEMailsAsync(_receiverDraftEmail);
|
var results = await ReceiverPageDataService.SearchReceiverEMailsAsync(_receiverDraftEmail);
|
||||||
|
if (searchVersion != _receiverEmailSearchVersion) return;
|
||||||
if (searchVersion != _receiverEmailSearchVersion)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_receiverEmailSuggestions = results
|
_receiverEmailSuggestions = results
|
||||||
.Where(email => !string.IsNullOrWhiteSpace(email))
|
.Where(em => !string.IsNullOrWhiteSpace(em))
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.OrderBy(email => email)
|
.OrderBy(em => em)
|
||||||
.Take(12)
|
.Take(12)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
_selectedReceiverEmailSuggestion = _receiverEmailSuggestions.FirstOrDefault(email =>
|
_selectedReceiverEmailSuggestion = _receiverEmailSuggestions.FirstOrDefault(em =>
|
||||||
string.Equals(email, _receiverDraftEmail, StringComparison.OrdinalIgnoreCase));
|
string.Equals(em, _receiverDraftEmail, StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -643,8 +743,8 @@
|
|||||||
|
|
||||||
Task SaveReceiverAsync()
|
Task SaveReceiverAsync()
|
||||||
{
|
{
|
||||||
var fullName = _receiverDraftName.Trim();
|
var fullName = _receiverDraftName.Trim();
|
||||||
var email = _receiverDraftEmail.Trim();
|
var email = _receiverDraftEmail.Trim();
|
||||||
var phoneNumber = _receiverDraftPhoneNumber.Trim();
|
var phoneNumber = _receiverDraftPhoneNumber.Trim();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(fullName))
|
if (string.IsNullOrWhiteSpace(fullName))
|
||||||
@@ -652,50 +752,38 @@
|
|||||||
_receiverPopupValidationMessage = "Bitte geben Sie einen Vor- und Nachnamen ein.";
|
_receiverPopupValidationMessage = "Bitte geben Sie einen Vor- und Nachnamen ein.";
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(email))
|
if (string.IsNullOrWhiteSpace(email))
|
||||||
{
|
{
|
||||||
_receiverPopupValidationMessage = "Bitte geben Sie eine E-Mail-Adresse ein.";
|
_receiverPopupValidationMessage = "Bitte geben Sie eine E-Mail-Adresse ein.";
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ReceiverEmailValidator.IsValid(email))
|
if (!ReceiverEmailValidator.IsValid(email))
|
||||||
{
|
{
|
||||||
_receiverPopupValidationMessage = "Bitte geben Sie eine gültige E-Mail-Adresse ein.";
|
_receiverPopupValidationMessage = "Bitte geben Sie eine gültige E-Mail-Adresse ein.";
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
if (_receivers.Any(r => string.Equals(r.Email, email, StringComparison.OrdinalIgnoreCase)))
|
||||||
if (_receivers.Any(receiver => string.Equals(receiver.Email, email, StringComparison.OrdinalIgnoreCase)))
|
|
||||||
{
|
{
|
||||||
_receiverPopupValidationMessage = "Diese E-Mail-Adresse wurde bereits hinzugefügt.";
|
_receiverPopupValidationMessage = "Diese E-Mail-Adresse wurde bereits hinzugefügt.";
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
_receivers.Add(new ReceiverDraft(Guid.NewGuid(), fullName, email, phoneNumber));
|
_receivers.Add(new ReceiverDraft(Guid.NewGuid(), fullName, email, phoneNumber));
|
||||||
|
PersistSession();
|
||||||
CloseAddReceiverPopup();
|
CloseAddReceiverPopup();
|
||||||
return Task.CompletedTask;
|
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 ──
|
// ── 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 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 {
|
.pdf-editor-wrapper {
|
||||||
position: relative;
|
width: 100%;
|
||||||
min-height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sender-editor-pdf-viewer {
|
.sender-editor-pdf-viewer {
|
||||||
@@ -61,6 +61,14 @@
|
|||||||
height: 100%;
|
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 {
|
.sender-editor-pdf-viewer .dxbrv-document-surface {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,92 +1,95 @@
|
|||||||
window.envelopeEditor = {
|
window.envelopeEditor = {
|
||||||
_overlaySyncState: {},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns click coordinates relative to the overlay element.
|
* Returns the click position normalised to [0,1] relative to the rendered PDF page
|
||||||
* @param {string} overlayId - The id of the overlay div
|
* element inside DxPdfViewer (or DxReportViewer as fallback).
|
||||||
* @param {number} clientX - MouseEventArgs.ClientX from Blazor
|
*
|
||||||
* @param {number} clientY - MouseEventArgs.ClientY from Blazor
|
* Normalising means the result is independent of zoom level: no matter how much the
|
||||||
* @returns {{ relX, relY, containerW, containerH }}
|
* 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) {
|
getClickCoordsOnPdfPage: function (viewerCssClass, clientX, clientY) {
|
||||||
const el = document.getElementById(overlayId);
|
|
||||||
if (!el) return null;
|
// 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 {
|
return {
|
||||||
relX: clientX - rect.left,
|
normX: normX,
|
||||||
relY: clientY - rect.top,
|
normY: normY,
|
||||||
containerW: rect.width,
|
pageIndex: targetIndex
|
||||||
containerH: rect.height
|
|
||||||
};
|
};
|
||||||
},
|
|
||||||
|
|
||||||
syncOverlayToPage: function (wrapperId, overlayId) {
|
|
||||||
const wrapper = document.getElementById(wrapperId);
|
|
||||||
const overlay = document.getElementById(overlayId);
|
|
||||||
|
|
||||||
if (!wrapper || !overlay) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = window.envelopeEditor._overlaySyncState[overlayId];
|
|
||||||
if (existing) {
|
|
||||||
return existing.sync();
|
|
||||||
}
|
|
||||||
|
|
||||||
const findTarget = (currentWrapper) => {
|
|
||||||
const page = currentWrapper.querySelector(".dxbrv-report-preview-content");
|
|
||||||
if (page) {
|
|
||||||
return page;
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentWrapper.querySelector(".dxbrv-report-preview-content-img") ||
|
|
||||||
currentWrapper.querySelector("img.dxbrv-report-preview-content-img") ||
|
|
||||||
currentWrapper.querySelector(".dxbrv-document-surface img");
|
|
||||||
};
|
|
||||||
|
|
||||||
const sync = () => {
|
|
||||||
const currentWrapper = document.getElementById(wrapperId);
|
|
||||||
const currentOverlay = document.getElementById(overlayId);
|
|
||||||
|
|
||||||
if (!currentWrapper || !currentOverlay) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = findTarget(currentWrapper);
|
|
||||||
if (!target) {
|
|
||||||
currentOverlay.style.left = "0px";
|
|
||||||
currentOverlay.style.top = "0px";
|
|
||||||
currentOverlay.style.width = "0px";
|
|
||||||
currentOverlay.style.height = "0px";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wrapperRect = currentWrapper.getBoundingClientRect();
|
|
||||||
const targetRect = target.getBoundingClientRect();
|
|
||||||
|
|
||||||
currentOverlay.style.left = `${targetRect.left - wrapperRect.left + currentWrapper.scrollLeft}px`;
|
|
||||||
currentOverlay.style.top = `${targetRect.top - wrapperRect.top + currentWrapper.scrollTop}px`;
|
|
||||||
currentOverlay.style.width = `${targetRect.width}px`;
|
|
||||||
currentOverlay.style.height = `${targetRect.height}px`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleSync = () => requestAnimationFrame(sync);
|
|
||||||
|
|
||||||
const observer = new MutationObserver(scheduleSync);
|
|
||||||
observer.observe(wrapper, { childList: true, subtree: true, attributes: true });
|
|
||||||
|
|
||||||
wrapper.addEventListener("scroll", scheduleSync, { passive: true });
|
|
||||||
window.addEventListener("resize", scheduleSync);
|
|
||||||
|
|
||||||
window.envelopeEditor._overlaySyncState[overlayId] = {
|
|
||||||
sync,
|
|
||||||
observer
|
|
||||||
};
|
|
||||||
|
|
||||||
sync();
|
|
||||||
setTimeout(sync, 50);
|
|
||||||
setTimeout(sync, 150);
|
|
||||||
setTimeout(sync, 400);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user