From ed44d841e70fe08db1cc74ee767d7cabfb205a24 Mon Sep 17 00:00:00 2001 From: TekH Date: Tue, 29 Sep 2026 16:01:42 +0200 Subject: [PATCH] Refactor PDF editor with custom rendering Replaced `DxPdfViewer` with a custom PDF rendering solution in `SenderPdfImageEditor` to improve flexibility and control. Added support for displaying placeholder messages and interactive signature fields. Introduced the `SenderPdfFieldOverlay` record to encapsulate field data. Updated `SenderEditorPage` logic to manage page counts, image URLs, and field overlays. Added a method to estimate PDF page counts. Refactored field removal logic to use index-based operations. Enhanced styling in `envelope-viewer.css` for improved layout and responsiveness. Updated localization files with new strings for page labels and placeholders. Adjusted JavaScript selectors in `envelope-editor.js` to support the new rendering approach. --- .../Resources/Resource.de-DE.resx | 2 + .../Resources/Resource.en-US.resx | 2 + .../Resources/Resource.fr-FR.resx | 2 + .../PdfEditor/SenderPdfFieldOverlay.cs | 9 ++ .../PdfEditor/SenderPdfImageEditor.razor | 97 ++++++++++++++++-- .../Pages/SenderEditorPage.razor | 75 ++++++++++---- .../wwwroot/css/envelope-viewer.css | 98 +++++++++++++++++++ .../wwwroot/js/envelope-editor.js | 5 +- 8 files changed, 262 insertions(+), 28 deletions(-) create mode 100644 EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/SenderPdfFieldOverlay.cs diff --git a/EnvelopeGenerator.Application/Resources/Resource.de-DE.resx b/EnvelopeGenerator.Application/Resources/Resource.de-DE.resx index f7ff6001..319d613e 100644 --- a/EnvelopeGenerator.Application/Resources/Resource.de-DE.resx +++ b/EnvelopeGenerator.Application/Resources/Resource.de-DE.resx @@ -735,7 +735,9 @@ Fehler beim Laden der Datei: {0} Bitte unterzeichnen Sie das beigefügte Dokument. Kein Dokument geladen + Keine Seiten zur Anzeige verfügbar. Noch keine Empfänger hinzugefügt. + Seite Signaturfeld platzieren Alle Signaturfelder entfernen Bitte wählen Sie eine PDF-Datei aus. diff --git a/EnvelopeGenerator.Application/Resources/Resource.en-US.resx b/EnvelopeGenerator.Application/Resources/Resource.en-US.resx index 01de8aec..df15c548 100644 --- a/EnvelopeGenerator.Application/Resources/Resource.en-US.resx +++ b/EnvelopeGenerator.Application/Resources/Resource.en-US.resx @@ -735,7 +735,9 @@ Error loading file: {0} Please sign the attached document. No document loaded + No pages to display. No receivers added yet. + Page Place signature field Remove all signature fields Please select a PDF file. diff --git a/EnvelopeGenerator.Application/Resources/Resource.fr-FR.resx b/EnvelopeGenerator.Application/Resources/Resource.fr-FR.resx index 50fcfe87..3940dff8 100644 --- a/EnvelopeGenerator.Application/Resources/Resource.fr-FR.resx +++ b/EnvelopeGenerator.Application/Resources/Resource.fr-FR.resx @@ -735,7 +735,9 @@ Erreur lors du chargement du fichier : {0} Veuillez signer le document ci-joint. Aucun document chargé + Aucune page à afficher. Aucun destinataire ajouté pour le moment. + Page Placer un champ de signature Supprimer tous les champs de signature Veuillez sélectionner un fichier PDF. diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/SenderPdfFieldOverlay.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/SenderPdfFieldOverlay.cs new file mode 100644 index 00000000..c2cda6fc --- /dev/null +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/SenderPdfFieldOverlay.cs @@ -0,0 +1,9 @@ +namespace EnvelopeGenerator.Server.Client.Components.PdfEditor; + +public sealed record SenderPdfFieldOverlay( + int Index, + int Page, + double XPt, + double YPt, + string ReceiverName, + string Color); diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/SenderPdfImageEditor.razor b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/SenderPdfImageEditor.razor index d40e51ef..a140780b 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/SenderPdfImageEditor.razor +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/SenderPdfImageEditor.razor @@ -1,31 +1,110 @@ -@using DevExpress.Blazor.PdfViewer +@using EnvelopeGenerator.Application.Resources +@using Microsoft.Extensions.Localization +@inject IStringLocalizer UiLocalizer -
- @if (PdfBytes is { Length: > 0 }) + @if (PageCount <= 0) { - +
@L("SenderEditor_NoPagesToDisplay", "Keine Seiten zur Anzeige verfügbar.")
+ } + else + { + @if (PageImageDataUrls.Count == 0 && !string.IsNullOrWhiteSpace(PdfDataUrl)) + { +
+ +
+ } + +
+ @for (var pageNumber = 1; pageNumber <= PageCount; pageNumber++) + { +
+
+ @if (PageImageDataUrls.TryGetValue(pageNumber, out var imageDataUrl) && !string.IsNullOrWhiteSpace(imageDataUrl)) + { + PDF Page @pageNumber + } + else + { +
@L("SenderEditor_PageLabel", "Seite") @pageNumber
+ } + + @foreach (var field in Fields.Where(f => f.Page == pageNumber)) + { + + } +
+
+ } +
}
@code { - [Parameter] - public byte[]? PdfBytes { get; set; } - [Parameter] public bool IsPlacementMode { get; set; } [Parameter] - public string ViewerCssClass { get; set; } = "sender-editor-pdf-viewer"; + public int PageCount { get; set; } + + [Parameter] + public IReadOnlyDictionary PageImageDataUrls { get; set; } = new Dictionary(); + + [Parameter] + public string? PdfDataUrl { get; set; } + + [Parameter] + public IReadOnlyList Fields { get; set; } = Array.Empty(); [Parameter] public EventCallback EditorClick { get; set; } + [Parameter] + public EventCallback RemoveField { get; set; } + + private static string GetFieldStyle(SenderPdfFieldOverlay field) + { + var leftPercent = (field.XPt / 595.0) * 100.0; + var topPercent = (field.YPt / 842.0) * 100.0; + var widthPercent = (127.44 / 595.0) * 100.0; + var heightPercent = (141.12 / 842.0) * 100.0; + + return string.Join(' ', + $"left:{leftPercent:F4}%;", + $"top:{topPercent:F4}%;", + $"width:{widthPercent:F4}%;", + $"height:{heightPercent:F4}%;", + $"border-color:{field.Color};", + $"background-color:{field.Color}22;"); + } + private async Task HandleClickAsync(MouseEventArgs args) { if (EditorClick.HasDelegate) await EditorClick.InvokeAsync(args); } + + private string L(string key, string fallback) + { + var value = UiLocalizer[key]; + return value.ResourceNotFound ? fallback : value.Value; + } } diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/SenderEditorPage.razor b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/SenderEditorPage.razor index bc3afdd9..57f8d1cd 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/SenderEditorPage.razor +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/SenderEditorPage.razor @@ -276,10 +276,13 @@ } else { - + } @@ -459,15 +462,16 @@ const double SigWidthPt = 1.77 * 72; // 127.44 pt const double SigHeightPt = 1.96 * 72; // 141.12 pt - // CssClass for DxPdfViewer — used by JS to locate page elements + // CssClass for JS click coordinate helper const string ViewerCssClass = "sender-editor-pdf-viewer"; // ── State ── bool _pdfLoaded = false; string _fileName = string.Empty; string? _errorMessage; - byte[]? _pdfBytes; // Current rendered PDF (original + placeholders burned in) byte[]? _originalPdfBytes; // Pristine upload — never modified, used as base for redraw + int _pageCount; + Dictionary _pageImageDataUrls = []; int? _loadedEnvelopeId; int? _editingEnvelopeId; bool _isLoadingEnvelopeForEdit; @@ -475,6 +479,14 @@ List _signatureFields = []; ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen" + IReadOnlyList FieldOverlays => _signatureFields + .Select((f, i) => new SenderPdfFieldOverlay(i, f.Page, f.XPt, f.YPt, f.ReceiverName, f.Color)) + .ToList(); + + string? PdfDataUrl => _originalPdfBytes is { Length: > 0 } + ? $"data:application/pdf;base64,{Convert.ToBase64String(_originalPdfBytes)}" + : null; + // ── Save state ── bool _isSaving = false; bool _savePopupVisible = false; @@ -582,7 +594,9 @@ _originalPdfBytes = document?.ByteData; _pdfLoaded = _originalPdfBytes is { Length: > 0 }; - _pdfBytes = _pdfLoaded ? _originalPdfBytes : null; + _pageCount = EstimatePdfPageCount(_originalPdfBytes) ?? + Math.Max(1, _signatureFields.Select(f => f.Page).DefaultIfEmpty(1).Max()); + _pageImageDataUrls.Clear(); _fileName = $"Envelope-{envelopeId}.pdf"; _pendingReceiverForPlacement = null; _loadedEnvelopeId = envelopeId; @@ -624,12 +638,11 @@ _originalPdfBytes = ms.ToArray(); _fileName = file.Name; _pdfLoaded = true; + _pageCount = EstimatePdfPageCount(_originalPdfBytes) ?? 1; + _pageImageDataUrls.Clear(); _signatureFields.Clear(); _pendingReceiverForPlacement = null; - // Rendered PDF starts as the clean original (no placeholders yet) - _pdfBytes = _originalPdfBytes; - Logger.LogInformation("PDF loaded: {Name} ({Size} bytes)", _fileName, _originalPdfBytes.Length); } catch (Exception ex) @@ -693,19 +706,18 @@ _signatureFields.Add(field); _pendingReceiverForPlacement = null; - // Keep original PDF bytes unchanged. Signature boxes are tracked in-memory. - _pdfBytes = _originalPdfBytes; - Logger.LogInformation( "[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) + async Task OnRemoveFieldByIndexAsync(int index) { - _signatureFields.Remove(field); - _pdfBytes = _originalPdfBytes; + if (index < 0 || index >= _signatureFields.Count) + return; + + _signatureFields.RemoveAt(index); await Task.CompletedTask; } @@ -714,10 +726,39 @@ { _signatureFields.Clear(); _pendingReceiverForPlacement = null; - _pdfBytes = _originalPdfBytes; await Task.CompletedTask; } + static int? EstimatePdfPageCount(byte[]? documentBytes) + { + if (documentBytes is null || documentBytes.Length == 0) + return null; + + try + { + var text = System.Text.Encoding.ASCII.GetString(documentBytes); + const string marker = "/Type /Page"; + var count = 0; + var index = 0; + + while (true) + { + index = text.IndexOf(marker, index, StringComparison.Ordinal); + if (index < 0) + break; + + count++; + index += marker.Length; + } + + return count > 0 ? count : null; + } + catch + { + return null; + } + } + void Cancel() => NavigationManager.NavigateTo("/sender"); void GoToDashboard() diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css index 24f1b009..50768729 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css @@ -56,6 +56,104 @@ height: 100%; } +.sender-image-editor { + overflow: auto; + padding: 1rem; +} + +.sender-image-editor__pages { + max-width: 980px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.sender-image-editor__native-preview { + max-width: 980px; + margin: 0 auto 1rem; + background: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 10px; + overflow: hidden; + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12); +} + +.sender-image-editor__native-frame { + width: 100%; + min-height: 70vh; + border: 0; + background: #f8fafc; +} + +.sender-image-editor__page-block { + background: #e2e8f0; + border: 1px solid #cbd5e1; + border-radius: 10px; + padding: 0.75rem; +} + +.sender-image-editor__page-canvas { + position: relative; + width: 100%; + aspect-ratio: 595 / 842; + background: #ffffff; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12); +} + +.sender-image-editor__page-image { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + pointer-events: none; +} + +.sender-image-editor__page-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: #64748b; + font-weight: 600; + font-size: 0.95rem; + letter-spacing: 0.01em; + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); +} + +.sender-image-editor__field { + position: absolute; + border: 2px solid; + border-radius: 8px; + font-size: 0.72rem; + font-weight: 700; + color: #0f172a; + padding: 0.2rem 0.3rem; + text-align: left; + line-height: 1.1; + cursor: pointer; + display: flex; + align-items: flex-start; + justify-content: flex-start; + overflow: hidden; +} + +.sender-image-editor__field:hover { + filter: brightness(0.96); +} + +.sender-image-editor__empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 320px; + color: #64748b; + font-weight: 600; +} + .sender-editor-pdf-viewer { width: 100%; height: 100%; diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/envelope-editor.js b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/envelope-editor.js index 377512f2..5dabd3ab 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/envelope-editor.js +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/envelope-editor.js @@ -25,9 +25,10 @@ window.envelopeEditor = { } // --- Candidate page elements (ordered by preference) --- - // DxPdfViewer renders individual pages as .dxbl-pdfv-page elements. - // DxReportViewer uses .dxbrv-report-preview-content-img as fallback. + // Sender editor custom renderer uses data-editor-page blocks. + // Legacy Dx viewers remain as fallback selectors. const pageSelectors = [ + '[data-editor-page]', '.dxbl-pdfv-page', '.dxbrv-report-preview-page', '.dxbrv-report-preview-content-img',