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.
This commit is contained in:
2026-05-31 16:38:41 +02:00
parent a668dfa3eb
commit 614a275740
4 changed files with 594 additions and 243 deletions

View File

@@ -17,7 +17,10 @@
@using DevExpress.Blazor.Reporting @using DevExpress.Blazor.Reporting
@using Microsoft.Extensions.Options @using Microsoft.Extensions.Options
@using EnvelopeGenerator.ReceiverUI.Options @using EnvelopeGenerator.ReceiverUI.Options
@using EnvelopeGenerator.ReceiverUI.Models
@implements IDisposable
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@inject AnnotationService AnnotationService
@inject IOptions<ApiOptions> AppOptions @inject IOptions<ApiOptions> AppOptions
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject InMemoryReportStorageWebExtension ReportStorage @inject InMemoryReportStorageWebExtension ReportStorage
@@ -43,10 +46,31 @@
@if(!string.IsNullOrWhiteSpace(SignatureValidationMessage)) { @if(!string.IsNullOrWhiteSpace(SignatureValidationMessage)) {
<div class="text-danger mb-2">@SignatureValidationMessage</div> <div class="text-danger mb-2">@SignatureValidationMessage</div>
} }
<div class="d-flex gap-2 flex-wrap"> <div class="d-flex gap-2 flex-wrap align-items-center">
<button class="btn btn-primary" @onclick="OpenSignaturePopupAsync"> <button class="btn @(_capturedSignature is not null ? "btn-outline-success" : "btn-primary")" @onclick="OpenSignaturePopupAsync">
@(SignatureApplied ? "Unterschrift erneuern" : "Unterschrift hinzufuegen") @if (_capturedSignature is not null) {
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
</svg>
<span>Unterschrift gespeichert</span>
} else {
<span>Unterschrift erstellen</span>
}
</button> </button>
@if (_annotations.Count > 0) {
@if (_capturedSignature is not null && !SignatureApplied) {
<span class="text-muted small fst-italic align-self-center">
@_checkedAnnotations.Count von @_annotations.Count @(_annotations.Count == 1 ? "Feld" : "Felder") markiert
</span>
<button class="btn btn-primary" @onclick="SubmitSignaturesAsync">
Unterschriften anwenden
</button>
} else if (!SignatureApplied) {
<span class="text-muted small fst-italic align-self-center">
Bitte zuerst eine Unterschrift erstellen, dann die Felder im Dokument markieren.
</span>
}
}
<button class="btn btn-success" disabled="@(!SignatureApplied)" @onclick="ExportSignedPdfAsync">Signiertes PDF exportieren</button> <button class="btn btn-success" disabled="@(!SignatureApplied)" @onclick="ExportSignedPdfAsync">Signiertes PDF exportieren</button>
@if (!string.IsNullOrWhiteSpace(EnvelopeKey)) { @if (!string.IsNullOrWhiteSpace(EnvelopeKey)) {
<button class="btn btn-outline-danger ms-auto" @onclick="LogoutAsync" disabled="@IsLoggingOut" title="Abmelden"> <button class="btn btn-outline-danger ms-auto" @onclick="LogoutAsync" disabled="@IsLoggingOut" title="Abmelden">
@@ -135,7 +159,7 @@
<FooterContentTemplate> <FooterContentTemplate>
<div class="d-flex gap-2 flex-wrap justify-content-end w-100"> <div class="d-flex gap-2 flex-wrap justify-content-end w-100">
<button class="btn btn-outline-secondary" @onclick="RenewSignatureAsync">Unterschrift erneuern</button> <button class="btn btn-outline-secondary" @onclick="RenewSignatureAsync">Unterschrift erneuern</button>
<button class="btn btn-primary" @onclick="ApplySignatureAsync">Zum Bericht hinzufuegen</button> <button class="btn btn-primary" @onclick="SaveSignatureAsync">Speichern</button>
<button class="btn btn-secondary" @onclick="CloseSignaturePopup">Schliessen</button> <button class="btn btn-secondary" @onclick="CloseSignaturePopup">Schliessen</button>
</div> </div>
</FooterContentTemplate> </FooterContentTemplate>
@@ -151,38 +175,47 @@
@code { @code {
const string SignatureTabDraw = "draw"; const string SignatureTabDraw = "draw";
const string SignatureTabText = "text"; const string SignatureTabText = "text";
const string SignatureTabImage = "image"; const string SignatureTabImage = "image";
const string DrawCanvasId = "receiver-signature-pad"; const string DrawCanvasId = "receiver-signature-pad";
const string TypedCanvasId = "receiver-typed-signature-pad"; const string TypedCanvasId = "receiver-typed-signature-pad";
const string ImageInputId = "receiver-signature-image-input"; const string ImageInputId = "receiver-signature-image-input";
const string ImageCanvasId = "receiver-image-signature-pad"; const string ImageCanvasId = "receiver-image-signature-pad";
readonly (string Text, string Value)[] TypedSignatureFonts = { readonly (string Text, string Value)[] TypedSignatureFonts = {
("Brush Script", "'Brush Script MT', cursive"), ("Brush Script", "'Brush Script MT', cursive"),
("Segoe Script", "'Segoe Script', cursive"), ("Segoe Script", "'Segoe Script', cursive"),
("Lucida Handwriting", "'Lucida Handwriting', cursive"), ("Lucida Handwriting", "'Lucida Handwriting', cursive"),
("Comic Sans", "'Comic Sans MS', cursive"), ("Comic Sans", "'Comic Sans MS', cursive"),
("Cursive", "cursive") ("Cursive", "cursive")
}; };
[Parameter] public string? EnvelopeKey { get; set; } [Parameter] public string? EnvelopeKey { get; set; }
DxReportViewer? reportViewer; DxReportViewer? reportViewer;
XtraReport? Report; XtraReport? Report;
bool SignatureApplied; bool SignatureApplied;
bool SignaturePopupVisible; bool SignaturePopupVisible;
string? SignatureValidationMessage; string? SignatureValidationMessage;
string? PopupValidationMessage; string? PopupValidationMessage;
string ActiveSignatureTab = SignatureTabDraw; string ActiveSignatureTab = SignatureTabDraw;
string TypedSignatureText = string.Empty; string TypedSignatureText = string.Empty;
string TypedSignatureFont = "'Brush Script MT', cursive"; string TypedSignatureFont = "'Brush Script MT', cursive";
string SignerFullName = string.Empty; string SignerFullName = string.Empty;
string SignerPosition = string.Empty; string SignerPosition = string.Empty;
string SignaturePlace = string.Empty; string SignaturePlace = string.Empty;
int ViewerKey; int ViewerKey;
bool IsLoggingOut; bool IsLoggingOut;
IReadOnlyList<AnnotationDto> _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<long> _checkedAnnotations = [];
DotNetObjectReference<ReportViewer>? _dotNetRef;
int _lastOverlayViewerKey = -1;
async Task LogoutAsync() { async Task LogoutAsync() {
if (string.IsNullOrWhiteSpace(EnvelopeKey) || IsLoggingOut) return; if (string.IsNullOrWhiteSpace(EnvelopeKey) || IsLoggingOut) return;
@@ -202,41 +235,39 @@
} }
} }
_annotations = await AnnotationService.GetAnnotationsAsync(EnvelopeKey ?? "fake");
if (!AppOptions.Value.ForceToUseFakeDocument && !string.IsNullOrWhiteSpace(EnvelopeKey)) { if (!AppOptions.Value.ForceToUseFakeDocument && !string.IsNullOrWhiteSpace(EnvelopeKey)) {
var (pdfBytes, _) = await DocumentService.GetDocumentAsync(EnvelopeKey); var (pdfBytes, _) = await DocumentService.GetDocumentAsync(EnvelopeKey);
if (pdfBytes is { Length: > 0 }) { if (pdfBytes is { Length: > 0 })
var report = new XtraReport(); _basePdfBytes = pdfBytes;
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;
}
} }
Report = CreateReportInstance(); var initialReport = BuildFreshBaseReport();
Report = initialReport;
} }
async Task<XtraReport?> BuildPdfReportAsync(string key) { protected override async Task OnAfterRenderAsync(bool firstRender) {
Console.WriteLine("BuildPdfReportAsync is invoked.."); if (firstRender)
var (pdfBytes, _) = await DocumentService.GetDocumentAsync(key); _dotNetRef = DotNetObjectReference.Create(this);
Console.WriteLine($"[BuildPdfReport] key={key}, pdfBytes={pdfBytes?.Length ?? 0}");
if (pdfBytes is not { Length: > 0 }) if (Report is not null && _annotations.Count > 0
return CreateReportInstance(); && _capturedSignature is not null && !SignatureApplied
&& _lastOverlayViewerKey != ViewerKey) {
_lastOverlayViewerKey = ViewerKey;
await JSRuntime.InvokeVoidAsync(
"receiverSignature.installAnnotationCheckboxes",
_annotations, _checkedAnnotations.ToArray(), _dotNetRef);
}
}
var report = new XtraReport(); [JSInvokable]
var detail = new DevExpress.XtraReports.UI.DetailBand(); public async Task OnAnnotationToggled(long annotationId, bool isChecked) {
report.Bands.Add(detail); if (isChecked)
var pdfContent = new DevExpress.XtraReports.UI.XRPdfContent { Source = pdfBytes, GenerateOwnPages = true }; _checkedAnnotations.Add(annotationId);
detail.Controls.Add(pdfContent); else
Console.WriteLine($"[BuildPdfReport] XRPdfContent added, Source length={pdfContent.Source?.Length ?? 0}"); _checkedAnnotations.Remove(annotationId);
await InvokeAsync(StateHasChanged);
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;
} }
async Task OpenSignaturePopupAsync() { async Task OpenSignaturePopupAsync() {
@@ -300,30 +331,61 @@
await JSRuntime.InvokeVoidAsync("receiverSignature.renderTypedSignature", TypedCanvasId, TypedSignatureText, TypedSignatureFont); await JSRuntime.InvokeVoidAsync("receiverSignature.renderTypedSignature", TypedCanvasId, TypedSignatureText, TypedSignatureFont);
} }
async Task ApplySignatureAsync() { async Task SaveSignatureAsync() {
if(string.IsNullOrWhiteSpace(SignerFullName)) { if (string.IsNullOrWhiteSpace(SignerFullName)) {
PopupValidationMessage = "Bitte geben Sie Vor- und Nachname ein."; PopupValidationMessage = "Bitte geben Sie Vor- und Nachname ein.";
return; return;
} }
if (string.IsNullOrWhiteSpace(SignaturePlace)) {
if(string.IsNullOrWhiteSpace(SignaturePlace)) {
PopupValidationMessage = "Bitte geben Sie den Ort ein."; PopupValidationMessage = "Bitte geben Sie den Ort ein.";
return; return;
} }
var signatureDataUrl = await GetActiveSignatureDataUrlAsync(); var signatureDataUrl = await GetActiveSignatureDataUrlAsync();
if (string.IsNullOrWhiteSpace(signatureDataUrl)) {
if(string.IsNullOrWhiteSpace(signatureDataUrl)) {
PopupValidationMessage = "Die Unterschrift ist fuer den PDF-Export erforderlich."; PopupValidationMessage = "Die Unterschrift ist fuer den PDF-Export erforderlich.";
return; return;
} }
PopupValidationMessage = null; PopupValidationMessage = null;
SignatureValidationMessage = null; SignatureValidationMessage = null;
Report = CreateSignedReportInstance(signatureDataUrl, SignerFullName.Trim(), SignerPosition.Trim(), SignaturePlace.Trim()); _capturedSignature = new(signatureDataUrl, SignerFullName.Trim(), SignerPosition.Trim(), SignaturePlace.Trim());
SignatureApplied = true;
// 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; 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++; ViewerKey++;
await InvokeAsync(StateHasChanged);
} }
async Task<string?> GetActiveSignatureDataUrlAsync() { async Task<string?> 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<AnnotationDto> annotations) {
var bottomMargin = report.Bands.OfType<BottomMarginBand>().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() { XtraReport CreateReportInstance() {
return ReportStorage.TryGetReport("LargeDatasetReport", out var savedReport) return ReportStorage.TryGetReport("LargeDatasetReport", out var savedReport)
? savedReport ? savedReport
: PredefinedReports.ReportsFactory.GetReport("LargeDatasetReport"); : 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) { static void AddSignature(XtraReport report, string signatureDataUrl, string signerFullName, string signerPosition, string signaturePlace) {
var imageBytes = Convert.FromBase64String(signatureDataUrl[(signatureDataUrl.IndexOf(',') + 1)..]); var imageBytes = Convert.FromBase64String(signatureDataUrl[(signatureDataUrl.IndexOf(',') + 1)..]);
using var imageStream = new MemoryStream(imageBytes); using var imageStream = new MemoryStream(imageBytes);
@@ -434,5 +537,73 @@
foreach(var control in controls) foreach(var control in controls)
bottomMargin.Controls.Remove(control); 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<BottomMarginBand>().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<XRControl>()
.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();
}
} }

View File

@@ -70,4 +70,65 @@ article {
.dx-blazor-reporting-container { .dx-blazor-reporting-container {
height: calc(100vh - 130px) !important; height: calc(100vh - 130px) !important;
width: 100% !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;
} }

View File

@@ -4,6 +4,9 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>EnvelopeGenerator.ReceiverUI</title> <title>EnvelopeGenerator.ReceiverUI</title>
<base href="/" /> <base href="/" />
<link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" /> <link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
@@ -62,7 +65,7 @@
<a class="dismiss">X</a> <a class="dismiss">X</a>
</div> </div>
<script src="_content/DevExpress.Blazor.Resources/js/preload-script.js"></script> <script src="_content/DevExpress.Blazor.Resources/js/preload-script.js"></script>
<script src="js/receiver-signature.js"></script> <script src="js/receiver-signature.js?v=9"></script>
<script src="_framework/blazor.webassembly.js"></script> <script src="_framework/blazor.webassembly.js"></script>
</body> </body>

View File

@@ -1,221 +1,337 @@
window.receiverSignature = (() => { window.receiverSignature = (() => {
const pads = new Map();
// ?? State ???????????????????????????????????????????????????????????????
const pads = new Map();
const typedSignatures = new Map(); const typedSignatures = new Map();
const imageSignatures = new Map(); const imageSignatures = new Map();
const overlayButtons = new Map(); // annotationId -> { btn, signed }
let _dotNetRef = null;
function getPosition(canvas, event) { // DevExpress Blazor Report Viewer selectors (confirmed via debugDumpViewerDom)
const rect = canvas.getBoundingClientRect(); const PAGE_IMG_SEL = '.dxbrv-report-preview-content-img';
const source = event.touches && event.touches.length ? event.touches[0] : event; const SCROLL_CONTAINER_SEL = '.dxbrv-surface-wrapper';
return { const VIEWER_WRAPPER_SEL = '.receiver-viewer-wrapper';
x: (source.clientX - rect.left) * (canvas.width / rect.width),
y: (source.clientY - rect.top) * (canvas.height / rect.height) // 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) { function _teardownCheckboxes() {
canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height); 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) { function initialize(canvasId) {
const canvas = document.getElementById(canvasId); const canvas = document.getElementById(canvasId);
if (!canvas || pads.has(canvasId)) if (!canvas || pads.has(canvasId)) return;
return; const ctx = canvas.getContext('2d');
ctx.lineWidth = 2.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.strokeStyle = '#111';
const context = canvas.getContext('2d');
context.lineWidth = 2.5;
context.lineCap = 'round';
context.lineJoin = 'round';
context.strokeStyle = '#111';
const state = { drawing: false, hasSignature: false }; const state = { drawing: false, hasSignature: false };
pads.set(canvasId, state); pads.set(canvasId, state);
const start = e => { e.preventDefault(); const p = _pos(canvas, e); state.drawing = true; ctx.beginPath(); ctx.moveTo(p.x, p.y); };
const start = event => { 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; };
event.preventDefault(); const end = e => { if (!state.drawing) return; e.preventDefault(); state.drawing = false; };
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;
};
canvas.addEventListener('mousedown', start); canvas.addEventListener('mousedown', start);
canvas.addEventListener('mousemove', move); canvas.addEventListener('mousemove', move);
window.addEventListener('mouseup', end); window.addEventListener('mouseup', end);
canvas.addEventListener('touchstart', start, { passive: false }); canvas.addEventListener('touchstart', start, { passive: false });
canvas.addEventListener('touchmove', move, { passive: false }); canvas.addEventListener('touchmove', move, { passive: false });
canvas.addEventListener('touchend', end, { passive: false }); canvas.addEventListener('touchend', end, { passive: false });
} }
function initializeTyped(canvasId) { function initializeTyped(canvasId) {
const canvas = document.getElementById(canvasId); const canvas = document.getElementById(canvasId);
if (!canvas || typedSignatures.has(canvasId)) if (!canvas || typedSignatures.has(canvasId)) return;
return;
typedSignatures.set(canvasId, { hasSignature: false }); typedSignatures.set(canvasId, { hasSignature: false });
} }
function initializeImage(inputId, canvasId) { function initializeImage(inputId, canvasId) {
const input = document.getElementById(inputId); const input = document.getElementById(inputId);
const canvas = document.getElementById(canvasId); const canvas = document.getElementById(canvasId);
if (!input || !canvas || imageSignatures.has(canvasId)) if (!input || !canvas || imageSignatures.has(canvasId)) return;
return;
const state = { hasSignature: false }; const state = { hasSignature: false };
imageSignatures.set(canvasId, state); imageSignatures.set(canvasId, state);
input.addEventListener('change', () => { input.addEventListener('change', () => {
const file = input.files && input.files.length ? input.files[0] : null; const file = input.files && input.files[0];
if (!file || !file.type.startsWith('image/')) { if (!file || !file.type.startsWith('image/')) { _clear(canvas); state.hasSignature = false; return; }
clearCanvas(canvas);
state.hasSignature = false;
return;
}
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
const image = new Image(); const img = new Image();
image.onload = () => { img.onload = () => {
const context = canvas.getContext('2d'); const ctx = canvas.getContext('2d'); _clear(canvas);
clearCanvas(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);
const padding = 10; ctx.drawImage(img, (canvas.width - img.width * s) / 2, (canvas.height - img.height * s) / 2, img.width * s, img.height * s);
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);
state.hasSignature = true; state.hasSignature = true;
}; };
image.src = reader.result; img.src = reader.result;
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
} }
function clear(canvasId) { function clear(canvasId) {
const canvas = document.getElementById(canvasId); const c = document.getElementById(canvasId); const s = pads.get(canvasId);
const state = pads.get(canvasId); if (c && s) { _clear(c); s.hasSignature = false; }
if (!canvas || !state)
return;
clearCanvas(canvas);
state.hasSignature = false;
} }
function clearTyped(canvasId) { function clearTyped(canvasId) {
const canvas = document.getElementById(canvasId); const c = document.getElementById(canvasId); const s = typedSignatures.get(canvasId);
const state = typedSignatures.get(canvasId); if (c && s) { _clear(c); s.hasSignature = false; }
if (!canvas || !state)
return;
clearCanvas(canvas);
state.hasSignature = false;
} }
function clearImage(inputId, canvasId) { function clearImage(inputId, canvasId) {
const input = document.getElementById(inputId); const inp = document.getElementById(inputId); const c = document.getElementById(canvasId); const s = imageSignatures.get(canvasId);
const canvas = document.getElementById(canvasId); if (c && s) { if (inp) inp.value = ''; _clear(c); s.hasSignature = false; }
const state = imageSignatures.get(canvasId);
if (!canvas || !state)
return;
if (input)
input.value = '';
clearCanvas(canvas);
state.hasSignature = false;
} }
function renderTypedSignature(canvasId, text, fontFamily) { function renderTypedSignature(canvasId, text, fontFamily) {
const canvas = document.getElementById(canvasId); const canvas = document.getElementById(canvasId); const state = typedSignatures.get(canvasId);
const state = typedSignatures.get(canvasId); if (!canvas || !state) return;
if (!canvas || !state) const value = (text || '').trim(); _clear(canvas);
return; if (!value) { state.hasSignature = false; return; }
const ctx = canvas.getContext('2d'); let fs = 54;
const value = (text || '').trim(); do { ctx.font = 'italic ' + fs + 'px ' + (fontFamily || 'cursive'); fs -= 2; }
clearCanvas(canvas); while (ctx.measureText(value).width > canvas.width - 30 && fs > 24);
ctx.fillStyle = '#111'; ctx.textBaseline = 'middle'; ctx.textAlign = 'center';
if (!value) { ctx.fillText(value, canvas.width / 2, canvas.height / 2);
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);
state.hasSignature = true; state.hasSignature = true;
} }
function getDataUrl(canvasId) { function getDataUrl(id) { const c = document.getElementById(id); const s = pads.get(id); return (c && s && s.hasSignature) ? c.toDataURL('image/png') : null; }
const canvas = document.getElementById(canvasId); function getTypedDataUrl(id) { const c = document.getElementById(id); const s = typedSignatures.get(id); return (c && s && s.hasSignature) ? c.toDataURL('image/png') : null; }
const state = pads.get(canvasId); function getImageDataUrl(id) { const c = document.getElementById(id); const s = imageSignatures.get(id); return (c && s && s.hasSignature) ? c.toDataURL('image/png') : null; }
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');
}
// ?? Public API ??????????????????????????????????????????????????????????
return { return {
initialize, installAnnotationCheckboxes: installAnnotationCheckboxes,
initializeTyped, debugDumpViewerDom: debugDumpViewerDom,
initializeImage, initialize: initialize,
clear, initializeTyped: initializeTyped,
clearTyped, initializeImage: initializeImage,
clearImage, clear: clear,
renderTypedSignature, clearTyped: clearTyped,
getDataUrl, clearImage: clearImage,
getTypedDataUrl, renderTypedSignature: renderTypedSignature,
getImageDataUrl getDataUrl: getDataUrl,
getTypedDataUrl: getTypedDataUrl,
getImageDataUrl: getImageDataUrl
}; };
})(); })();