diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderEditorPage.razor b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderEditorPage.razor index 27fbca0d..1c211e79 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderEditorPage.razor +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderEditorPage.razor @@ -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 Logger @inject EnvelopeReceiverPageDataService ReceiverPageDataService +@inject IMemoryCache MemoryCache + @@ -81,9 +84,10 @@ } - - @* Clear all fields *@ @if (_signatureFields.Count > 0) { } @@ -209,63 +205,13 @@ } else { - @* PDF viewer + overlay wrapper *@ -
- - @* DxPdfViewer β€” zoom fixed to 1.0 for reliable coordinate mapping *@ + @* PDF viewer β€” click capture active only in placement mode *@ +
- - @* Transparent overlay for click capture (active only in placement mode) *@ -
- - @* Render placed signature field placeholders *@ - @foreach (var field in _signatureFields) - { - var f = field; // capture for lambda -
- - Unterschrift - - - S.@f.Page - - -
- } -
}
@@ -356,36 +302,80 @@ @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 _signatureFields = []; + ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufΓΌgen" + List _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 _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( - "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( + "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 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 Fields, + string FileName, + List Receivers); } diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css index 3d2c698c..229583a8 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css @@ -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; diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/envelope-editor.js b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/envelope-editor.js index 5cf1605c..377512f2 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/envelope-editor.js +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/envelope-editor.js @@ -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); } };