From 614a2757406895f023ce39ecb5103dab642c30b9 Mon Sep 17 00:00:00 2001 From: TekH Date: Sun, 31 May 2026 16:38:41 +0200 Subject: [PATCH] Enhance signature handling and annotation features - Added dependency injection for `AnnotationService`, `DocumentService`, and `AuthService` in `ReportViewer.razor`. - Improved signature button logic with dynamic appearance and feedback. - Introduced annotation checkbox overlays for marking signature fields. - Refactored signature saving and application logic into `SaveSignatureAsync` and `SubmitSignaturesAsync`. - Added `BuildFreshBaseReport` and `AddAnnotationPlaceholders` for dynamic report creation. - Implemented annotation-specific signature placement with `AddSignatureAtAnnotation`. - Enhanced state management for annotations and signature overlays. - Updated `app.css` with styles for annotation checkboxes. - Added cache-control headers and versioned JavaScript in `index.html`. - Improved `receiver-signature.js` with annotation checkbox management, optimized signature pad logic, and debugging utilities. - Performed general code cleanup and optimization for maintainability. --- .../Pages/ReportViewer.razor | 323 ++++++++++--- .../wwwroot/css/app.css | 61 +++ .../wwwroot/index.html | 5 +- .../wwwroot/js/receiver-signature.js | 448 +++++++++++------- 4 files changed, 594 insertions(+), 243 deletions(-) diff --git a/EnvelopeGenerator.ReceiverUI/Pages/ReportViewer.razor b/EnvelopeGenerator.ReceiverUI/Pages/ReportViewer.razor index 4ad0e86d..d42538ba 100644 --- a/EnvelopeGenerator.ReceiverUI/Pages/ReportViewer.razor +++ b/EnvelopeGenerator.ReceiverUI/Pages/ReportViewer.razor @@ -17,7 +17,10 @@ @using DevExpress.Blazor.Reporting @using Microsoft.Extensions.Options @using EnvelopeGenerator.ReceiverUI.Options +@using EnvelopeGenerator.ReceiverUI.Models +@implements IDisposable @inject IJSRuntime JSRuntime +@inject AnnotationService AnnotationService @inject IOptions AppOptions @inject NavigationManager Navigation @inject InMemoryReportStorageWebExtension ReportStorage @@ -43,10 +46,31 @@ @if(!string.IsNullOrWhiteSpace(SignatureValidationMessage)) {
@SignatureValidationMessage
} -
- + @if (_annotations.Count > 0) { + @if (_capturedSignature is not null && !SignatureApplied) { + + @_checkedAnnotations.Count von @_annotations.Count @(_annotations.Count == 1 ? "Feld" : "Felder") markiert + + + } else if (!SignatureApplied) { + + Bitte zuerst eine Unterschrift erstellen, dann die Felder im Dokument markieren. + + } + } @if (!string.IsNullOrWhiteSpace(EnvelopeKey)) { - +
@@ -151,38 +175,47 @@ @code { - const string SignatureTabDraw = "draw"; - const string SignatureTabText = "text"; - const string SignatureTabImage = "image"; - const string DrawCanvasId = "receiver-signature-pad"; - const string TypedCanvasId = "receiver-typed-signature-pad"; - const string ImageInputId = "receiver-signature-image-input"; - const string ImageCanvasId = "receiver-image-signature-pad"; +const string SignatureTabDraw = "draw"; +const string SignatureTabText = "text"; +const string SignatureTabImage = "image"; +const string DrawCanvasId = "receiver-signature-pad"; +const string TypedCanvasId = "receiver-typed-signature-pad"; +const string ImageInputId = "receiver-signature-image-input"; +const string ImageCanvasId = "receiver-image-signature-pad"; - readonly (string Text, string Value)[] TypedSignatureFonts = { - ("Brush Script", "'Brush Script MT', cursive"), - ("Segoe Script", "'Segoe Script', cursive"), - ("Lucida Handwriting", "'Lucida Handwriting', cursive"), - ("Comic Sans", "'Comic Sans MS', cursive"), - ("Cursive", "cursive") - }; +readonly (string Text, string Value)[] TypedSignatureFonts = { + ("Brush Script", "'Brush Script MT', cursive"), + ("Segoe Script", "'Segoe Script', cursive"), + ("Lucida Handwriting", "'Lucida Handwriting', cursive"), + ("Comic Sans", "'Comic Sans MS', cursive"), + ("Cursive", "cursive") +}; - [Parameter] public string? EnvelopeKey { get; set; } +[Parameter] public string? EnvelopeKey { get; set; } - DxReportViewer? reportViewer; - XtraReport? Report; - bool SignatureApplied; - bool SignaturePopupVisible; - string? SignatureValidationMessage; - string? PopupValidationMessage; - string ActiveSignatureTab = SignatureTabDraw; - string TypedSignatureText = string.Empty; - string TypedSignatureFont = "'Brush Script MT', cursive"; - string SignerFullName = string.Empty; - string SignerPosition = string.Empty; - string SignaturePlace = string.Empty; - int ViewerKey; - bool IsLoggingOut; +DxReportViewer? reportViewer; +XtraReport? Report; +bool SignatureApplied; +bool SignaturePopupVisible; +string? SignatureValidationMessage; +string? PopupValidationMessage; +string ActiveSignatureTab = SignatureTabDraw; +string TypedSignatureText = string.Empty; +string TypedSignatureFont = "'Brush Script MT', cursive"; +string SignerFullName = string.Empty; +string SignerPosition = string.Empty; +string SignaturePlace = string.Empty; +int ViewerKey; +bool IsLoggingOut; + +IReadOnlyList _annotations = []; +record SignatureCapture(string DataUrl, string FullName, string Position, string Place); +SignatureCapture? _capturedSignature; +byte[]? _basePdfBytes; +// annotation IDs the user has checked via overlay checkboxes +readonly HashSet _checkedAnnotations = []; +DotNetObjectReference? _dotNetRef; +int _lastOverlayViewerKey = -1; async Task LogoutAsync() { if (string.IsNullOrWhiteSpace(EnvelopeKey) || IsLoggingOut) return; @@ -202,41 +235,39 @@ } } + _annotations = await AnnotationService.GetAnnotationsAsync(EnvelopeKey ?? "fake"); + if (!AppOptions.Value.ForceToUseFakeDocument && !string.IsNullOrWhiteSpace(EnvelopeKey)) { var (pdfBytes, _) = await DocumentService.GetDocumentAsync(EnvelopeKey); - if (pdfBytes is { Length: > 0 }) { - var report = new XtraReport(); - var detail = new DevExpress.XtraReports.UI.DetailBand(); - report.Bands.Add(detail); - detail.Controls.Add(new DevExpress.XtraReports.UI.XRPdfContent { Source = pdfBytes, GenerateOwnPages = true }); - ReportStorage.SetData(report, EnvelopeKey); - Report = ReportStorage.TryGetReport(EnvelopeKey, out var stored) ? stored : report; - return; - } + if (pdfBytes is { Length: > 0 }) + _basePdfBytes = pdfBytes; } - Report = CreateReportInstance(); + var initialReport = BuildFreshBaseReport(); + Report = initialReport; } - async Task BuildPdfReportAsync(string key) { - Console.WriteLine("BuildPdfReportAsync is invoked.."); - var (pdfBytes, _) = await DocumentService.GetDocumentAsync(key); - Console.WriteLine($"[BuildPdfReport] key={key}, pdfBytes={pdfBytes?.Length ?? 0}"); + protected override async Task OnAfterRenderAsync(bool firstRender) { + if (firstRender) + _dotNetRef = DotNetObjectReference.Create(this); - if (pdfBytes is not { Length: > 0 }) - return CreateReportInstance(); + if (Report is not null && _annotations.Count > 0 + && _capturedSignature is not null && !SignatureApplied + && _lastOverlayViewerKey != ViewerKey) { + _lastOverlayViewerKey = ViewerKey; + await JSRuntime.InvokeVoidAsync( + "receiverSignature.installAnnotationCheckboxes", + _annotations, _checkedAnnotations.ToArray(), _dotNetRef); + } + } - var report = new XtraReport(); - var detail = new DevExpress.XtraReports.UI.DetailBand(); - report.Bands.Add(detail); - var pdfContent = new DevExpress.XtraReports.UI.XRPdfContent { Source = pdfBytes, GenerateOwnPages = true }; - detail.Controls.Add(pdfContent); - Console.WriteLine($"[BuildPdfReport] XRPdfContent added, Source length={pdfContent.Source?.Length ?? 0}"); - - ReportStorage.SetData(report, key); - var result = ReportStorage.TryGetReport(key, out var stored) ? stored : report; - Console.WriteLine($"[BuildPdfReport] TryGetReport success={stored is not null}, bands={result?.Bands?.Count}"); - return result; + [JSInvokable] + public async Task OnAnnotationToggled(long annotationId, bool isChecked) { + if (isChecked) + _checkedAnnotations.Add(annotationId); + else + _checkedAnnotations.Remove(annotationId); + await InvokeAsync(StateHasChanged); } async Task OpenSignaturePopupAsync() { @@ -300,30 +331,61 @@ await JSRuntime.InvokeVoidAsync("receiverSignature.renderTypedSignature", TypedCanvasId, TypedSignatureText, TypedSignatureFont); } - async Task ApplySignatureAsync() { - if(string.IsNullOrWhiteSpace(SignerFullName)) { + async Task SaveSignatureAsync() { + if (string.IsNullOrWhiteSpace(SignerFullName)) { PopupValidationMessage = "Bitte geben Sie Vor- und Nachname ein."; return; } - - if(string.IsNullOrWhiteSpace(SignaturePlace)) { + if (string.IsNullOrWhiteSpace(SignaturePlace)) { PopupValidationMessage = "Bitte geben Sie den Ort ein."; return; } - var signatureDataUrl = await GetActiveSignatureDataUrlAsync(); - - if(string.IsNullOrWhiteSpace(signatureDataUrl)) { + if (string.IsNullOrWhiteSpace(signatureDataUrl)) { PopupValidationMessage = "Die Unterschrift ist fuer den PDF-Export erforderlich."; return; } PopupValidationMessage = null; SignatureValidationMessage = null; - Report = CreateSignedReportInstance(signatureDataUrl, SignerFullName.Trim(), SignerPosition.Trim(), SignaturePlace.Trim()); - SignatureApplied = true; + _capturedSignature = new(signatureDataUrl, SignerFullName.Trim(), SignerPosition.Trim(), SignaturePlace.Trim()); + + // If no annotations, apply immediately (no checkbox step needed) + if (_annotations.Count == 0) { + var freshReport = BuildFreshBaseReport(); + AddSignature(freshReport, _capturedSignature.DataUrl, _capturedSignature.FullName, _capturedSignature.Position, _capturedSignature.Place); + Report = freshReport; + SignatureApplied = true; + SignaturePopupVisible = false; + ViewerKey++; + return; + } + + // Close popup; checkboxes will appear on the PDF via OnAfterRenderAsync SignaturePopupVisible = false; + _lastOverlayViewerKey = -1; // force overlay reinstall + await InvokeAsync(StateHasChanged); + } + + async Task SubmitSignaturesAsync() { + if (_checkedAnnotations.Count == 0) { + SignatureValidationMessage = "Bitte markieren Sie mindestens ein Unterschriftsfeld im Dokument."; + return; + } + if (_checkedAnnotations.Count < _annotations.Count) { + SignatureValidationMessage = $"Bitte markieren Sie alle {_annotations.Count} Unterschriftsfelder. Noch {_annotations.Count - _checkedAnnotations.Count} offen."; + return; + } + + SignatureValidationMessage = null; + var freshReport = BuildFreshBaseReport(); + foreach (var ann in _annotations) + AddSignatureAtAnnotation(freshReport, ann, _capturedSignature!.DataUrl, _capturedSignature.FullName, _capturedSignature.Position, _capturedSignature.Place); + + Report = freshReport; + SignatureApplied = true; ViewerKey++; + await InvokeAsync(StateHasChanged); } async Task GetActiveSignatureDataUrlAsync() { @@ -352,19 +414,60 @@ } } + XtraReport BuildFreshBaseReport() { + if (_basePdfBytes is { Length: > 0 }) { + var report = new XtraReport(); + var detail = new DevExpress.XtraReports.UI.DetailBand(); + report.Bands.Add(detail); + detail.Controls.Add(new DevExpress.XtraReports.UI.XRPdfContent { Source = _basePdfBytes, GenerateOwnPages = true }); + return report; + } + return CreateReportInstance(); + } + + static void AddAnnotationPlaceholders(XtraReport report, IReadOnlyList annotations) { + var bottomMargin = report.Bands.OfType().FirstOrDefault(); + if (bottomMargin is null) { + bottomMargin = new BottomMarginBand(); + report.Bands.Add(bottomMargin); + } + + const float sigWidth = 230F; + const float sigHeight = 154F; + const float bottomPad = 6F; + const float defaultTopPad = 8F; + const float maxBandHeight = 210F; + + float requiredHeight = defaultTopPad + sigHeight + bottomPad; + bottomMargin.HeightF = Math.Min(maxBandHeight, Math.Max(bottomMargin.HeightF, requiredHeight)); + float topPad = Math.Max(0F, bottomMargin.HeightF - bottomPad - sigHeight); + + foreach (var ann in annotations) { + float sigX = (float)(ann.X); + var annotId = ann.Id.ToString(); + + var placeholder = new XRLabel { + Name = $"receiverSignaturePlaceholder_{annotId}", + Text = "\u270e Bitte unterschreiben", + BoundsF = new RectangleF(sigX, topPad, sigWidth, sigHeight), + Borders = BorderSide.All, + BorderColor = System.Drawing.Color.FromArgb(230, 81, 0), + BackColor = System.Drawing.Color.FromArgb(30, 255, 236, 153), + Font = new DXFont("Open Sans", 10F, DXFontStyle.Regular), + ForeColor = System.Drawing.Color.FromArgb(94, 38, 0), + TextAlignment = TextAlignment.MiddleCenter + }; + + bottomMargin.Controls.Add(placeholder); + } + } + XtraReport CreateReportInstance() { return ReportStorage.TryGetReport("LargeDatasetReport", out var savedReport) ? savedReport : PredefinedReports.ReportsFactory.GetReport("LargeDatasetReport"); } - XtraReport CreateSignedReportInstance(string signatureDataUrl, string signerFullName, string signerPosition, string signaturePlace) { - var baseReportName = string.IsNullOrWhiteSpace(EnvelopeKey) ? "LargeDatasetReport" : EnvelopeKey; - var report = ReportStorage.TryGetReport(baseReportName, out var stored) ? stored : CreateReportInstance(); - AddSignature(report, signatureDataUrl, signerFullName, signerPosition, signaturePlace); - return report; - } - static void AddSignature(XtraReport report, string signatureDataUrl, string signerFullName, string signerPosition, string signaturePlace) { var imageBytes = Convert.FromBase64String(signatureDataUrl[(signatureDataUrl.IndexOf(',') + 1)..]); using var imageStream = new MemoryStream(imageBytes); @@ -434,5 +537,73 @@ foreach(var control in controls) bottomMargin.Controls.Remove(control); } + + static void AddSignatureAtAnnotation(XtraReport report, AnnotationDto? annotation, string signatureDataUrl, string signerFullName, string signerPosition, string signaturePlace) { + var imageBytes = Convert.FromBase64String(signatureDataUrl[(signatureDataUrl.IndexOf(',') + 1)..]); + using var imageStream = new MemoryStream(imageBytes); + var imageSource = new ImageSource(DXImage.FromStream(imageStream)); + var bottomMargin = report.Bands.OfType().FirstOrDefault(); + + if (bottomMargin is null) { + bottomMargin = new BottomMarginBand(); + report.Bands.Add(bottomMargin); + } + + var annotId = annotation?.Id.ToString() ?? "0"; + RemoveExistingSignatureById(bottomMargin, annotId); + + const float sigWidth = 230F; + const float sigImgHeight = 70F; + const float infoHeight = 65F; + const float innerGap = 5F; + const float bottomPad = 6F; + const float defaultTopPad = 8F; + const float maxBandHeight = 210F; + + float sigX = (float)(annotation?.X ?? 390.0); + float requiredHeight = defaultTopPad + sigImgHeight + innerGap + infoHeight + bottomPad; + bottomMargin.HeightF = Math.Min(maxBandHeight, Math.Max(bottomMargin.HeightF, requiredHeight)); + float topPad = Math.Max(0F, bottomMargin.HeightF - bottomPad - infoHeight - innerGap - sigImgHeight); + float imageY = topPad; + float labelY = imageY + sigImgHeight + innerGap; + + var signatureInformation = string.IsNullOrWhiteSpace(signerPosition) + ? $"Empfaengerunterschrift\n{signerFullName}\n{signaturePlace}, {DateTime.Now:d}" + : $"Empfaengerunterschrift\n{signerFullName}\n{signerPosition}\n{signaturePlace}, {DateTime.Now:d}"; + + var signature = new XRPictureBox { + Name = $"receiverSignatureImage_{annotId}", + ImageSource = imageSource, + BoundsF = new RectangleF(sigX, imageY, sigWidth, sigImgHeight), + Sizing = ImageSizeMode.ZoomImage, + Borders = BorderSide.Bottom, + BorderColor = System.Drawing.Color.FromArgb(73, 80, 87) + }; + + var signatureLabel = new XRLabel { + Name = $"receiverSignatureLabel_{annotId}", + Text = signatureInformation, + Multiline = true, + BoundsF = new RectangleF(sigX, labelY, sigWidth, infoHeight), + Font = new DXFont("Open Sans", 8F, DXFontStyle.Regular), + ForeColor = System.Drawing.Color.FromArgb(73, 80, 87), + TextAlignment = TextAlignment.TopLeft + }; + + bottomMargin.Controls.AddRange(new XRControl[] { signature, signatureLabel }); + } + + static void RemoveExistingSignatureById(BottomMarginBand bottomMargin, string annotId) { + var controls = bottomMargin.Controls + .Cast() + .Where(c => c.Name == $"receiverSignatureImage_{annotId}" || c.Name == $"receiverSignatureLabel_{annotId}") + .ToArray(); + foreach (var c in controls) + bottomMargin.Controls.Remove(c); + } + + public void Dispose() { + _dotNetRef?.Dispose(); + } } diff --git a/EnvelopeGenerator.ReceiverUI/wwwroot/css/app.css b/EnvelopeGenerator.ReceiverUI/wwwroot/css/app.css index aecfc78b..beeed1f7 100644 --- a/EnvelopeGenerator.ReceiverUI/wwwroot/css/app.css +++ b/EnvelopeGenerator.ReceiverUI/wwwroot/css/app.css @@ -70,4 +70,65 @@ article { .dx-blazor-reporting-container { height: calc(100vh - 130px) !important; width: 100% !important; +} + +/* ── Force DevExpress viewer pages into a single centered column ─────────── */ +.dxbrv-report-preview-content { + display: flex !important; + flex-direction: column !important; + align-items: center !important; +} + +/* ── Annotation signature checkbox overlays ─────────────────────────────── */ +.annot-sig-cb-wrapper { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + border-radius: 4px; + font-size: 0.82rem; + font-weight: 700; + font-family: "Segoe UI", Arial, sans-serif; + padding: 0 10px; + overflow: hidden; + transition: background-color 0.15s, border-color 0.15s, box-shadow 0.15s; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + background-color: rgba(255, 236, 153, 0.97); + border: 2px dashed #e65100; + color: #5d2600; + user-select: none; +} + +.annot-sig-cb-wrapper:hover { + background-color: rgba(255, 213, 79, 1); + border-color: #bf360c; + box-shadow: 0 3px 10px rgba(230, 81, 0, 0.4); +} + +.annot-sig-cb-wrapper--checked { + background-color: rgba(200, 230, 201, 0.97); + border: 2px solid #2e7d32; + color: #1b5e20; + box-shadow: 0 1px 4px rgba(46, 125, 50, 0.25); +} + +.annot-sig-cb-wrapper--checked:hover { + background-color: rgba(165, 214, 167, 1); + border-color: #1b5e20; + box-shadow: 0 3px 10px rgba(46, 125, 50, 0.35); +} + +.annot-sig-cb { + width: 18px; + height: 18px; + flex-shrink: 0; + cursor: pointer; + accent-color: #2e7d32; +} + +.annot-sig-cb__label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; } \ No newline at end of file diff --git a/EnvelopeGenerator.ReceiverUI/wwwroot/index.html b/EnvelopeGenerator.ReceiverUI/wwwroot/index.html index 9fe2cb56..e16a3618 100644 --- a/EnvelopeGenerator.ReceiverUI/wwwroot/index.html +++ b/EnvelopeGenerator.ReceiverUI/wwwroot/index.html @@ -4,6 +4,9 @@ + + + EnvelopeGenerator.ReceiverUI @@ -62,7 +65,7 @@ X - + diff --git a/EnvelopeGenerator.ReceiverUI/wwwroot/js/receiver-signature.js b/EnvelopeGenerator.ReceiverUI/wwwroot/js/receiver-signature.js index 4b700448..36d4b5a5 100644 --- a/EnvelopeGenerator.ReceiverUI/wwwroot/js/receiver-signature.js +++ b/EnvelopeGenerator.ReceiverUI/wwwroot/js/receiver-signature.js @@ -1,221 +1,337 @@ window.receiverSignature = (() => { - const pads = new Map(); + + // ?? State ??????????????????????????????????????????????????????????????? + const pads = new Map(); const typedSignatures = new Map(); const imageSignatures = new Map(); + const overlayButtons = new Map(); // annotationId -> { btn, signed } + let _dotNetRef = null; - function getPosition(canvas, event) { - const rect = canvas.getBoundingClientRect(); - const source = event.touches && event.touches.length ? event.touches[0] : event; - return { - x: (source.clientX - rect.left) * (canvas.width / rect.width), - y: (source.clientY - rect.top) * (canvas.height / rect.height) + // DevExpress Blazor Report Viewer selectors (confirmed via debugDumpViewerDom) + const PAGE_IMG_SEL = '.dxbrv-report-preview-content-img'; + const SCROLL_CONTAINER_SEL = '.dxbrv-surface-wrapper'; + const VIEWER_WRAPPER_SEL = '.receiver-viewer-wrapper'; + + // DX report coordinate space (1/100 inch, A4) + const DX_PAGE_WIDTH = 827.0; + const DX_PAGE_HEIGHT = 1169.0; + + // Signature field size in DX units + const SIG_WIDTH_DX = 230.0; + const SIG_HEIGHT_DX = 154.0; + + // ?? Annotation Checkboxes ???????????????????????????????????????????????? + + // Active install context — holds everything needed to reposition on resize/scroll + let _installCtx = null; + + function installAnnotationCheckboxes(annotations, checkedIds, dotNetRef) { + _dotNetRef = dotNetRef; + + // Tear down previous install completely + _teardownCheckboxes(); + + if (!annotations || annotations.length === 0) return; + + const ctx = { + annotations, + checkedIds: new Set(Array.isArray(checkedIds) ? checkedIds : []), + scrollEl: null, + pageEls: [], + resizeObs: null, + onScroll: null, + onResize: null, + resizeTimer: null, }; + _installCtx = ctx; + + _waitForCheckboxPages(ctx); } - function clearCanvas(canvas) { - canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height); + function _teardownCheckboxes() { + document.querySelectorAll('.annot-sig-cb-wrapper').forEach(el => el.remove()); + overlayButtons.clear(); + + if (!_installCtx) return; + const ctx = _installCtx; + + if (ctx.resizeObs) { ctx.resizeObs.disconnect(); ctx.resizeObs = null; } + if (ctx.scrollEl && ctx.onScroll) ctx.scrollEl.removeEventListener('scroll', ctx.onScroll); + if (ctx.onResize) window.removeEventListener('resize', ctx.onResize); + if (ctx.resizeTimer) clearTimeout(ctx.resizeTimer); + + _installCtx = null; } + function _waitForCheckboxPages(ctx) { + const wrapper = document.querySelector(VIEWER_WRAPPER_SEL); + if (!wrapper) return; + + if (!_tryInstallCheckboxes(ctx)) { + const observer = new MutationObserver(() => { + if (_tryInstallCheckboxes(ctx)) observer.disconnect(); + }); + observer.observe(wrapper, { childList: true, subtree: true }); + setTimeout(() => observer.disconnect(), 15000); + } + } + + function _tryInstallCheckboxes(ctx) { + const scrollEl = document.querySelector(SCROLL_CONTAINER_SEL); + if (!scrollEl) return false; + + const pageEls = Array.from(document.querySelectorAll(PAGE_IMG_SEL)); + if (pageEls.length === 0) return false; + + if (getComputedStyle(scrollEl).position === 'static') + scrollEl.style.position = 'relative'; + + ctx.scrollEl = scrollEl; + ctx.pageEls = pageEls; + + // Initial render + _renderAllCheckboxes(ctx); + + // ResizeObserver — watches every page image for size changes (zoom in/out) + const ro = new ResizeObserver(() => _repositionAll(ctx)); + pageEls.forEach(el => ro.observe(el)); + ctx.resizeObs = ro; + + // Scroll — reposition when user scrolls the viewer + ctx.onScroll = () => _repositionAll(ctx); + scrollEl.addEventListener('scroll', ctx.onScroll, { passive: true }); + + // Window resize — debounced 60 ms + ctx.onResize = () => { + if (ctx.resizeTimer) clearTimeout(ctx.resizeTimer); + ctx.resizeTimer = setTimeout(() => _repositionAll(ctx), 60); + }; + window.addEventListener('resize', ctx.onResize, { passive: true }); + + return true; + } + + function _repositionAll(ctx) { + if (!ctx || !ctx.scrollEl) return; + + const scrollEl = ctx.scrollEl; + const pageEls = Array.from(document.querySelectorAll(PAGE_IMG_SEL)); // re-query in case DOM changed + if (pageEls.length === 0) return; + + const scrollRect = scrollEl.getBoundingClientRect(); + + ctx.annotations.forEach(ann => { + const wrapper = document.querySelector(`.annot-sig-cb-wrapper[data-annot-id="${ann.id}"]`); + if (!wrapper) return; + + const pageEl = pageEls[(ann.page || 1) - 1] ?? pageEls[pageEls.length - 1]; + if (!pageEl) return; + + const pageRect = pageEl.getBoundingClientRect(); + if (pageRect.width === 0 || pageRect.height === 0) return; + + const scaleX = pageRect.width / DX_PAGE_WIDTH; + const scaleY = pageRect.height / DX_PAGE_HEIGHT; + + const absLeft = pageRect.left - scrollRect.left + scrollEl.scrollLeft + (ann.x || 0) * scaleX; + const absTop = pageRect.top - scrollRect.top + scrollEl.scrollTop + (ann.y || 0) * scaleY; + const boxW = SIG_WIDTH_DX * scaleX; + const boxH = SIG_HEIGHT_DX * scaleY; + + wrapper.style.left = Math.round(absLeft) + 'px'; + wrapper.style.top = Math.round(absTop) + 'px'; + wrapper.style.width = Math.round(boxW) + 'px'; + wrapper.style.height = Math.round(boxH) + 'px'; + }); + } + + function _renderAllCheckboxes(ctx) { + const scrollEl = ctx.scrollEl; + const pageEls = ctx.pageEls; + const scrollRect = scrollEl.getBoundingClientRect(); + + ctx.annotations.forEach(ann => { + const pageEl = pageEls[(ann.page || 1) - 1] ?? pageEls[pageEls.length - 1]; + if (!pageEl) return; + + const pageRect = pageEl.getBoundingClientRect(); + if (pageRect.width === 0 || pageRect.height === 0) return; + + const scaleX = pageRect.width / DX_PAGE_WIDTH; + const scaleY = pageRect.height / DX_PAGE_HEIGHT; + + const absLeft = pageRect.left - scrollRect.left + scrollEl.scrollLeft + (ann.x || 0) * scaleX; + const absTop = pageRect.top - scrollRect.top + scrollEl.scrollTop + (ann.y || 0) * scaleY; + const boxW = SIG_WIDTH_DX * scaleX; + const boxH = SIG_HEIGHT_DX * scaleY; + + const isChecked = ctx.checkedIds.has(ann.id); + _createCheckboxOverlay(ann.id, scrollEl, absLeft, absTop, boxW, boxH, isChecked); + }); + } + + function _createCheckboxOverlay(annotationId, container, left, top, width, height, isChecked) { + const wrapper = document.createElement('div'); + wrapper.className = 'annot-sig-cb-wrapper' + (isChecked ? ' annot-sig-cb-wrapper--checked' : ''); + wrapper.setAttribute('data-annot-id', String(annotationId)); + + Object.assign(wrapper.style, { + position: 'absolute', + left: Math.round(left) + 'px', + top: Math.round(top) + 'px', + width: Math.round(width) + 'px', + height: Math.round(height) + 'px', + zIndex: '9999', + cursor: 'pointer', + boxSizing: 'border-box', + }); + + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = isChecked; + cb.className = 'annot-sig-cb'; + cb.setAttribute('aria-label', 'Unterschriftsfeld bestaetigen'); + + const label = document.createElement('span'); + label.className = 'annot-sig-cb__label'; + label.textContent = isChecked ? '\u2713 Bestaetigt' : '\u270e Hier unterschreiben'; + + wrapper.appendChild(cb); + wrapper.appendChild(label); + + wrapper.addEventListener('click', (e) => { + if (e.target !== cb) cb.checked = !cb.checked; + const checked = cb.checked; + wrapper.classList.toggle('annot-sig-cb-wrapper--checked', checked); + label.textContent = checked ? '\u2713 Bestaetigt' : '\u270e Hier unterschreiben'; + if (_installCtx) { + if (checked) _installCtx.checkedIds.add(annotationId); + else _installCtx.checkedIds.delete(annotationId); + } + if (_dotNetRef) + _dotNetRef.invokeMethodAsync('OnAnnotationToggled', annotationId, checked); + }); + + container.appendChild(wrapper); + overlayButtons.set(annotationId, { btn: wrapper, signed: isChecked }); + } + + function debugDumpViewerDom() { + const wrapper = document.querySelector(VIEWER_WRAPPER_SEL); + if (!wrapper) { console.warn('[annot] .receiver-viewer-wrapper not found'); return; } + console.group('[annot] viewer DOM snapshot'); + const cs = new Set(); + wrapper.querySelectorAll('*').forEach(el => el.classList.forEach(c => cs.add(c))); + console.log('classes:', [...cs].sort().join(', ')); + console.log('scroll container:', document.querySelector(SCROLL_CONTAINER_SEL)); + console.log('page images:', document.querySelectorAll(PAGE_IMG_SEL)); + console.groupEnd(); + } + + // ?? Signature Pad ??????????????????????????????????????????????????????? + + function _pos(canvas, event) { + const r = canvas.getBoundingClientRect(); + const s = (event.touches && event.touches.length) ? event.touches[0] : event; + return { x: (s.clientX - r.left) * (canvas.width / r.width), y: (s.clientY - r.top) * (canvas.height / r.height) }; + } + + function _clear(canvas) { canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height); } + function initialize(canvasId) { const canvas = document.getElementById(canvasId); - if (!canvas || pads.has(canvasId)) - return; - - const context = canvas.getContext('2d'); - context.lineWidth = 2.5; - context.lineCap = 'round'; - context.lineJoin = 'round'; - context.strokeStyle = '#111'; - + if (!canvas || pads.has(canvasId)) return; + const ctx = canvas.getContext('2d'); + ctx.lineWidth = 2.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.strokeStyle = '#111'; const state = { drawing: false, hasSignature: false }; pads.set(canvasId, state); - - const start = event => { - event.preventDefault(); - const pos = getPosition(canvas, event); - state.drawing = true; - context.beginPath(); - context.moveTo(pos.x, pos.y); - }; - - const move = event => { - if (!state.drawing) - return; - - event.preventDefault(); - const pos = getPosition(canvas, event); - context.lineTo(pos.x, pos.y); - context.stroke(); - state.hasSignature = true; - }; - - const end = event => { - if (!state.drawing) - return; - - event.preventDefault(); - state.drawing = false; - }; - + const start = e => { e.preventDefault(); const p = _pos(canvas, e); state.drawing = true; ctx.beginPath(); ctx.moveTo(p.x, p.y); }; + const move = e => { if (!state.drawing) return; e.preventDefault(); const p = _pos(canvas, e); ctx.lineTo(p.x, p.y); ctx.stroke(); state.hasSignature = true; }; + const end = e => { if (!state.drawing) return; e.preventDefault(); state.drawing = false; }; canvas.addEventListener('mousedown', start); canvas.addEventListener('mousemove', move); window.addEventListener('mouseup', end); canvas.addEventListener('touchstart', start, { passive: false }); - canvas.addEventListener('touchmove', move, { passive: false }); - canvas.addEventListener('touchend', end, { passive: false }); + canvas.addEventListener('touchmove', move, { passive: false }); + canvas.addEventListener('touchend', end, { passive: false }); } function initializeTyped(canvasId) { const canvas = document.getElementById(canvasId); - if (!canvas || typedSignatures.has(canvasId)) - return; - + if (!canvas || typedSignatures.has(canvasId)) return; typedSignatures.set(canvasId, { hasSignature: false }); } function initializeImage(inputId, canvasId) { - const input = document.getElementById(inputId); + const input = document.getElementById(inputId); const canvas = document.getElementById(canvasId); - if (!input || !canvas || imageSignatures.has(canvasId)) - return; - + if (!input || !canvas || imageSignatures.has(canvasId)) return; const state = { hasSignature: false }; imageSignatures.set(canvasId, state); - input.addEventListener('change', () => { - const file = input.files && input.files.length ? input.files[0] : null; - if (!file || !file.type.startsWith('image/')) { - clearCanvas(canvas); - state.hasSignature = false; - return; - } - + const file = input.files && input.files[0]; + if (!file || !file.type.startsWith('image/')) { _clear(canvas); state.hasSignature = false; return; } const reader = new FileReader(); reader.onload = () => { - const image = new Image(); - image.onload = () => { - const context = canvas.getContext('2d'); - clearCanvas(canvas); - - const padding = 10; - const maxWidth = canvas.width - padding * 2; - const maxHeight = canvas.height - padding * 2; - const scale = Math.min(maxWidth / image.width, maxHeight / image.height, 1); - const width = image.width * scale; - const height = image.height * scale; - const x = (canvas.width - width) / 2; - const y = (canvas.height - height) / 2; - - context.drawImage(image, x, y, width, height); + const img = new Image(); + img.onload = () => { + const ctx = canvas.getContext('2d'); _clear(canvas); + const p = 10, mw = canvas.width - p * 2, mh = canvas.height - p * 2; + const s = Math.min(mw / img.width, mh / img.height, 1); + ctx.drawImage(img, (canvas.width - img.width * s) / 2, (canvas.height - img.height * s) / 2, img.width * s, img.height * s); state.hasSignature = true; }; - image.src = reader.result; + img.src = reader.result; }; reader.readAsDataURL(file); }); } function clear(canvasId) { - const canvas = document.getElementById(canvasId); - const state = pads.get(canvasId); - if (!canvas || !state) - return; - - clearCanvas(canvas); - state.hasSignature = false; + const c = document.getElementById(canvasId); const s = pads.get(canvasId); + if (c && s) { _clear(c); s.hasSignature = false; } } function clearTyped(canvasId) { - const canvas = document.getElementById(canvasId); - const state = typedSignatures.get(canvasId); - if (!canvas || !state) - return; - - clearCanvas(canvas); - state.hasSignature = false; + const c = document.getElementById(canvasId); const s = typedSignatures.get(canvasId); + if (c && s) { _clear(c); s.hasSignature = false; } } function clearImage(inputId, canvasId) { - const input = document.getElementById(inputId); - const canvas = document.getElementById(canvasId); - const state = imageSignatures.get(canvasId); - if (!canvas || !state) - return; - - if (input) - input.value = ''; - - clearCanvas(canvas); - state.hasSignature = false; + const inp = document.getElementById(inputId); const c = document.getElementById(canvasId); const s = imageSignatures.get(canvasId); + if (c && s) { if (inp) inp.value = ''; _clear(c); s.hasSignature = false; } } function renderTypedSignature(canvasId, text, fontFamily) { - const canvas = document.getElementById(canvasId); - const state = typedSignatures.get(canvasId); - if (!canvas || !state) - return; - - const value = (text || '').trim(); - clearCanvas(canvas); - - if (!value) { - state.hasSignature = false; - return; - } - - const context = canvas.getContext('2d'); - const maxWidth = canvas.width - 30; - let fontSize = 54; - - do { - context.font = `italic ${fontSize}px ${fontFamily || 'cursive'}`; - fontSize -= 2; - } while (context.measureText(value).width > maxWidth && fontSize > 24); - - context.fillStyle = '#111'; - context.textBaseline = 'middle'; - context.textAlign = 'center'; - context.fillText(value, canvas.width / 2, canvas.height / 2); + const canvas = document.getElementById(canvasId); const state = typedSignatures.get(canvasId); + if (!canvas || !state) return; + const value = (text || '').trim(); _clear(canvas); + if (!value) { state.hasSignature = false; return; } + const ctx = canvas.getContext('2d'); let fs = 54; + do { ctx.font = 'italic ' + fs + 'px ' + (fontFamily || 'cursive'); fs -= 2; } + while (ctx.measureText(value).width > canvas.width - 30 && fs > 24); + ctx.fillStyle = '#111'; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; + ctx.fillText(value, canvas.width / 2, canvas.height / 2); state.hasSignature = true; } - function getDataUrl(canvasId) { - const canvas = document.getElementById(canvasId); - const state = pads.get(canvasId); - if (!canvas || !state || !state.hasSignature) - return null; - - return canvas.toDataURL('image/png'); - } - - function getTypedDataUrl(canvasId) { - const canvas = document.getElementById(canvasId); - const state = typedSignatures.get(canvasId); - if (!canvas || !state || !state.hasSignature) - return null; - - return canvas.toDataURL('image/png'); - } - - function getImageDataUrl(canvasId) { - const canvas = document.getElementById(canvasId); - const state = imageSignatures.get(canvasId); - if (!canvas || !state || !state.hasSignature) - return null; - - return canvas.toDataURL('image/png'); - } + function getDataUrl(id) { const c = document.getElementById(id); const s = pads.get(id); return (c && s && s.hasSignature) ? c.toDataURL('image/png') : null; } + function getTypedDataUrl(id) { const c = document.getElementById(id); const s = typedSignatures.get(id); return (c && s && s.hasSignature) ? c.toDataURL('image/png') : null; } + function getImageDataUrl(id) { const c = document.getElementById(id); const s = imageSignatures.get(id); return (c && s && s.hasSignature) ? c.toDataURL('image/png') : null; } + // ?? Public API ?????????????????????????????????????????????????????????? return { - initialize, - initializeTyped, - initializeImage, - clear, - clearTyped, - clearImage, - renderTypedSignature, - getDataUrl, - getTypedDataUrl, - getImageDataUrl + installAnnotationCheckboxes: installAnnotationCheckboxes, + debugDumpViewerDom: debugDumpViewerDom, + initialize: initialize, + initializeTyped: initializeTyped, + initializeImage: initializeImage, + clear: clear, + clearTyped: clearTyped, + clearImage: clearImage, + renderTypedSignature: renderTypedSignature, + getDataUrl: getDataUrl, + getTypedDataUrl: getTypedDataUrl, + getImageDataUrl: getImageDataUrl }; })();