Files
EnvelopeGenerator/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/SenderEditorPage.razor
TekH 021d7cfe29 Add PDF download functionality to editor components
Introduced the ability to download PDFs in the `SenderPdfImageEditor` component:
- Added `PdfFileName` parameter to specify the file name.
- Implemented `CanDownloadPdf` property to enable/disable downloads.
- Created `OnDownloadPdfAsync` method to handle download logic via JS interop.

Updated `SenderPdfImageEditorToolbar` to support downloads:
- Added `CanDownloadPdf` and `DownloadPdf` parameters.
- Enabled "Download PDF" button based on `CanDownloadPdf`.
- Added `DownloadPdfAsync` method to trigger the download callback.

Enhanced `SenderEditorPage` to pass `PdfFileName` to `SenderPdfImageEditor`.

Added `downloadDataUrlAsFile` function in `envelope-editor.js` to facilitate file downloads using a temporary `<a>` element.

Made minor adjustments for consistency, including adding `ZoomStepPercent` to the toolbar and refining `SetZoomPercent` logic.
2026-09-30 16:48:51 +02:00

1258 lines
55 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/sender/editor"
@rendermode InteractiveWebAssembly
@using DevExpress.Blazor.Reporting.Models
@using DevExpress.Blazor
@using EnvelopeGenerator.Application.Common.Dto
@using EnvelopeGenerator.Application.Envelopes.Commands
@using EnvelopeGenerator.Application.Resources
@using EnvelopeGenerator.Server.Client.Components.PdfEditor
@using EnvelopeGenerator.Server.Client.Services
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.Extensions.Localization
@inject IJSRuntime JSRuntime
@inject NavigationManager NavigationManager
@inject AppVersionService AppVersion
@inject ILogger<SenderEditorPage> Logger
@inject DocumentService DocumentService
@inject PdfRenderService PdfRenderService
@inject ReceiverService ReceiverService
@inject EnvelopeService EnvelopeService
@inject IStringLocalizer<Resource> UiLocalizer
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<link href="_content/DevExpress.Blazor.Viewer/css/dx-blazor-viewer-components.bs5.css" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
<script src="@AppVersion.GetVersionedUrl("js/envelope-editor.js")"></script>
<div class="envelope-viewer-layout">
@* ── Action Bar ── *@
<div class="envelope-action-bar">
<div class="envelope-action-bar__inner"
style="flex-direction: row; align-items: center; padding: 0.35rem 1.5rem; gap: 0.75rem;">
@* Left: Title + meta inputs + receivers *@
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: stretch; gap: 0.5rem;">
@* ── Title & Message inputs ── *@
<div style="display: flex; align-items: flex-start; gap: 0.5rem; flex-wrap: wrap;">
@* Titel *@
<div style="display: flex; flex-direction: column; gap: 0.2rem; flex: 1 1 180px; min-width: 140px;">
<label style="font-size: 0.65rem; font-weight: 600; color: #6b7280;
text-transform: uppercase; letter-spacing: 0.05em;">
@L("Title", "Titel") <span style="color:#dc2626;">*</span>
</label>
<input type="text"
value="@_envelopeTitle"
@oninput="e => _envelopeTitle = e.Value?.ToString() ?? string.Empty"
placeholder='@L("SenderEditor_Title_Placeholder", "z.B. Arbeitsvertrag 2025")'
maxlength="120"
style="font-size: 0.78rem; font-weight: 500; color: #1f2937;
border: 1px solid #d1d5db; border-radius: 5px;
padding: 0.3rem 0.55rem; outline: none; width: 100%;
background: #fff;
@(_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle) ? "border-color:#dc2626;" : "")" />
@if (_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle))
{
<span style="font-size:0.6rem;color:#dc2626;">@L("RequiredField", "Pflichtfeld")</span>
}
</div>
@* Nachricht *@
<div style="display: flex; flex-direction: column; gap: 0.2rem; flex: 2 1 260px; min-width: 180px;">
<label style="font-size: 0.65rem; font-weight: 600; color: #6b7280;
text-transform: uppercase; letter-spacing: 0.05em;">
@L("Message", "Nachricht") <span style="color:#6b7280;font-weight:400;">(@L("Optional", "optional"))</span>
</label>
<input type="text"
value="@_envelopeMessage"
@oninput="e => _envelopeMessage = e.Value?.ToString() ?? string.Empty"
placeholder='@L("SenderEditor_Message_Placeholder", "Bitte unterzeichnen Sie das beigefügte Dokument.")'
maxlength="500"
style="font-size: 0.78rem; color: #1f2937;
border: 1px solid #d1d5db; border-radius: 5px;
padding: 0.3rem 0.55rem; outline: none; width: 100%;
background: #fff;" />
</div>
@* PDF filename badge *@
@if (_pdfLoaded)
{
<div style="display:flex;align-items:flex-end;gap:0.4rem;padding-bottom:0.05rem;flex-shrink:0;">
<span style="font-size: 0.7rem; color: #6b7280;">@_fileName</span>
@if (_signatureFields.Count > 0)
{
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem;
background: #ede9fe; border-radius: 0.25rem; color: #6d28d9;
font-weight: 500; font-size: 0.7rem; white-space: nowrap;">
@_signatureFields.Count @L("SenderEditor_SignatureField", "Signaturfeld")@(_signatureFields.Count != 1 ? "er" : "")
</span>
}
</div>
}
</div>
<div class="sender-receivers-panel">
<div class="sender-receivers-panel__header">
<div class="sender-receivers-panel__title-wrap">
<span class="sender-receivers-panel__title">@L("Receiver", "Empfänger")</span>
<span class="sender-receivers-panel__count">@_receivers.Count</span>
</div>
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn"
@onclick="OpenAddReceiverPopup"
title='@L("AddReceiver", "Empfänger hinzufügen")'>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 1a.5.5 0 0 1 .5.5v6h6a.5.5 0 0 1 0 1h-6v6a.5.5 0 0 1-1 0v-6h-6a.5.5 0 0 1 0-1h6v-6A.5.5 0 0 1 8 1z" />
</svg>
<span class="pdf-toolbar__btn-text">@L("AddReceiver", "Empfänger hinzufügen")</span>
</button>
</div>
@if (_receivers.Count == 0)
{
<div class="sender-receivers-panel__empty">
@L("SenderEditor_NoReceiversAdded", "Noch keine Empfänger hinzugefügt.")
</div>
}
else
{
<div class="sender-receivers-list">
@foreach (var receiver in _receivers)
{
<div class="sender-receiver-chip"
style="border-left: 3px solid @receiver.Color;">
<div class="sender-receiver-chip__body">
<div class="sender-receiver-chip__name">@receiver.FullName</div>
<div class="sender-receiver-chip__email">@receiver.Email</div>
@if (!string.IsNullOrWhiteSpace(receiver.PhoneNumber))
{
<div class="sender-receiver-chip__phone">@receiver.PhoneNumber</div>
}
</div>
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-toolbar-action-btn--compact
@(_pendingReceiverForPlacement?.Id == receiver.Id ? "pdf-toolbar__btn--signature-change-active" : "")"
@onclick="() => ActivatePlacementForReceiver(receiver)"
style="@(_pendingReceiverForPlacement?.Id == receiver.Id ? $"background:{receiver.Color};color:#fff;border-color:{receiver.Color};" : $"color:{receiver.Color};border-color:{receiver.Color};")"
title='@L("SenderEditor_PlaceSignatureField", "Signaturfeld platzieren")'>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10z" />
</svg>
<span class="pdf-toolbar__btn-text">@L("SenderEditor_AddSignature", "Signatur hinzufügen")</span>
</button>
</div>
}
</div>
}
</div>
</div>
@* Right: Buttons *@
<div style="display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0;">
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
@onclick="Cancel"
title='@L("Cancel", "Abbrechen")'>
@L("Cancel", "Abbrechen")
</button>
@* PDF Upload *@
<label class="pdf-toolbar__btn"
title='@L("UploadPdf", "PDF hochladen")'
style="cursor: @((_isPageReady && !_isPdfUploadInProgress) ? "pointer" : "not-allowed");
opacity:@((_isPageReady && !_isPdfUploadInProgress) ? "1" : "0.6");
display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.75rem; font-size: 0.75rem; font-weight: 500;">
@if (_isPdfUploadInProgress)
{
<span class="spinner-border spinner-border-sm" role="status" style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z" />
</svg>
}
@if (_isPdfUploadInProgress)
{
@L("UploadPdf", "PDF hochladen")
@:
@:...
}
else if (_isPageReady)
{
@L("UploadPdf", "PDF hochladen")
}
else
{
@L("Loading", "Wird geladen")
@:
@:...
}
<InputFile OnChange="OnPdfFileSelectedAsync"
accept=".pdf"
disabled="@(!_isPageReady || _isPdfUploadInProgress)"
style="display: none;" />
</label>
@if (_pdfLoaded && _signatureFields.Count > 0)
{
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
@onclick="ClearAllFieldsAsync"
title='@L("SenderEditor_RemoveAllSignatureFields", "Alle Signaturfelder entfernen")'>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1 0-2h3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1h3a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3h11V2h-11v1z" />
</svg>
</button>
}
<button class="pdf-toolbar__btn pdf-toolbar__btn--draft"
@onclick="SaveDraftAsync"
disabled="@_isSaving"
title='@L("Save", "Speichern")'>
@if (_isSaving)
{
<span class="spinner-border spinner-border-sm me-1" role="status"
style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M2 2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V5.414A2 2 0 0 0 15.414 4L12 0.586A2 2 0 0 0 10.586 0H2zm9 1.5v2A1.5 1.5 0 0 1 9.5 7h-3A1.5 1.5 0 0 1 5 5.5v-4h5.586a1 1 0 0 1 .707.293L14.207 4.707A1 1 0 0 1 14.5 5.414V12a.5.5 0 0 1-.5.5H2a.5.5 0 0 1-.5-.5V4A.5.5 0 0 1 2 3.5h9z"/>
<path d="M5.5 1.5v4a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-4h-4zM4 9a1 1 0 0 0-1 1v3h10v-3a1 1 0 0 0-1-1H4z"/>
</svg>
}
<span class="pdf-toolbar__btn-text">@L("Save", "Speichern")</span>
</button>
<button class="pdf-toolbar__btn pdf-toolbar__btn--success"
@onclick="SendAsync"
disabled="@_isSaving"
title='@L("SendEnvelope", "Umschlag senden")'
style="background: linear-gradient(135deg,#059669,#047857); color:#fff; border-radius:6px; padding:0.3rem 0.75rem; font-size:0.75rem; font-weight:600; border:none;">
@if (_isSaving)
{
<span class="spinner-border spinner-border-sm me-1" role="status"
style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
}
else
{
<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>
}
@L("Send", "Senden")
</button>
</div>
</div>
@* Placement mode hint bar *@
@if (_pendingReceiverForPlacement is not null)
{
<div style="background: @_pendingReceiverForPlacement.Color; color: white; font-size: 0.75rem; font-weight: 500;
padding: 0.3rem 1.5rem; text-align: center; letter-spacing: 0.01em;">
📌 @L("SenderEditor_ClickDocumentFor", "Klicken Sie auf die Stelle im Dokument für") <strong>@_pendingReceiverForPlacement.FullName</strong>.
&nbsp;<button @onclick="CancelPlacement"
style="background:none;border:none;color:white;cursor:pointer;font-size:0.75rem;text-decoration:underline;padding:0;">
@L("Cancel", "Abbrechen")
</button>
</div>
}
</div>
@* ── Content ── *@
<div class="envelope-content" style="padding: 0; overflow: hidden;">
@if (!_pdfLoaded)
{
@* Empty state *@
<div class="d-flex justify-content-center align-items-center h-100">
<div class="text-center" style="max-width: 420px;">
<div style="width: 72px; height: 72px; background: rgba(255,255,255,0.15);
border-radius: 50%; display: flex; align-items: center;
justify-content: center; margin: 0 auto 1.25rem;">
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" fill="white" viewBox="0 0 16 16">
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z" />
</svg>
</div>
<h5 class="text-white mb-2">@L("SenderEditor_NoDocumentLoaded", "Kein Dokument geladen")</h5>
<p class="text-white mb-4" style="opacity: 0.75; font-size: 0.85rem;">
@L("SenderEditor_UploadPdfToPlaceFields", "Laden Sie eine PDF-Datei hoch, um Signaturfelder zu platzieren.")
</p>
<label class="btn btn-light btn-sm"
style="cursor: @((_isPageReady && !_isPdfUploadInProgress) ? "pointer" : "not-allowed");
opacity:@((_isPageReady && !_isPdfUploadInProgress) ? "1" : "0.8");
font-weight: 500;">
@if (_isPdfUploadInProgress)
{
<span class="spinner-border spinner-border-sm me-1" role="status" style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z" />
</svg>
}
@if (_isPdfUploadInProgress)
{
@L("UploadPdf", "PDF hochladen")
@:
@:...
}
else if (_isPageReady)
{
@L("UploadPdf", "PDF hochladen")
}
else
{
@L("Loading", "Wird geladen")
@:
@:...
}
<InputFile OnChange="OnPdfFileSelectedAsync"
accept=".pdf"
disabled="@(!_isPageReady || _isPdfUploadInProgress)"
style="display: none;" />
</label>
</div>
</div>
}
else if (_errorMessage is not null)
{
<div class="error-container">
<div class="alert alert-danger shadow-lg m-4">
<strong>@L("Error", "Fehler"):</strong> @_errorMessage
</div>
</div>
}
else
{
<SenderPdfImageEditor IsPlacementMode="@(_pendingReceiverForPlacement is not null)"
PageCount="@_pageCount"
Pages="@_pagePreviews"
PdfDataUrl="@PdfDataUrl"
PdfFileName="@_fileName"
Fields="@FieldOverlays"
EditorClick="OnPdfAreaClickAsync"
MoveField="OnMoveFieldAsync"
RemoveField="OnRemoveFieldByIndexAsync" />
}
</div>
</div>
<DxPopup @bind-Visible="_receiverPopupVisible"
HeaderText='@L("AddReceiver", "Empfänger hinzufügen")'
ShowFooter="true"
CloseOnEscape="true"
Width="720px"
CssClass="sender-receiver-popup">
<BodyContentTemplate Context="popupContext">
<div class="sender-receiver-popup__body">
<div class="sender-receiver-popup__form-grid">
<div class="sender-receiver-popup__field">
<div class="sender-receiver-popup__label">@L("FullName", "Vor- und Nachname")</div>
<DxTextBox Text="@_receiverDraftName"
TextChanged="OnReceiverNameChanged"
NullText="Max Mustermann"
ClearButtonDisplayMode="DataEditorClearButtonDisplayMode.Auto"
BindValueMode="BindValueMode.OnInput"
CssClass="w-100" />
</div>
<div class="sender-receiver-popup__field">
<div class="sender-receiver-popup__label">@L("PhoneNumber", "Telefonnummer") (@L("Optional", "optional"))</div>
<DxTextBox Text="@_receiverDraftPhoneNumber"
TextChanged="OnReceiverPhoneNumberChanged"
NullText="+49 170 1234567"
ClearButtonDisplayMode="DataEditorClearButtonDisplayMode.Auto"
BindValueMode="BindValueMode.OnInput"
CssClass="w-100" />
</div>
<div class="sender-receiver-popup__field">
<div class="sender-receiver-popup__label">@L("EmailAddress", "E-Mail-Adresse")</div>
<div @onkeydown="OnReceiverEmailKeyDownAsync">
<DxTextBox Text="@_receiverDraftEmail"
TextChanged="OnReceiverEmailTextChangedAsync"
NullText="name@beispiel.de"
ClearButtonDisplayMode="DataEditorClearButtonDisplayMode.Auto"
BindValueMode="BindValueMode.OnInput"
CssClass="w-100" />
</div>
<div class="sender-receiver-popup__suggestions-shell">
@if (_receiverEmailSuggestions.Count > 0)
{
<div class="sender-receiver-popup__suggestions">
<DxListBox TData="string"
TValue="string"
Data="@_receiverEmailSuggestions"
Value="@_selectedReceiverEmailSuggestion"
ValueChanged="OnReceiverEmailSuggestionCommittedAsync"
SelectionMode="ListBoxSelectionMode.Single"
CssClass="w-100" />
</div>
}
</div>
</div>
</div>
<div class="sender-receiver-popup__hint">
@L("SenderEditor_EmailSuggestionsHint", "Bereits verwendete E-Mail-Adressen werden bei der Eingabe vorgeschlagen.")
</div>
@if (_isReceiverEmailSearchRunning)
{
<div class="sender-receiver-popup__loading">@L("SenderEditor_EmailSuggestionsLoading", "E-Mail-Vorschläge werden geladen…")</div>
}
@if (!string.IsNullOrWhiteSpace(_receiverPopupValidationMessage))
{
<div class="sender-receiver-popup__validation">@_receiverPopupValidationMessage</div>
}
</div>
</BodyContentTemplate>
<FooterContentTemplate>
<div class="sender-receiver-popup__footer">
<DxButton Text='@L("Cancel", "Abbrechen")'
RenderStyle="ButtonRenderStyle.Secondary"
Click="CloseAddReceiverPopup" />
<DxButton Text='@L("AddReceiver", "Empfänger hinzufügen")'
RenderStyle="ButtonRenderStyle.Primary"
Click="SaveReceiverAsync" />
</div>
</FooterContentTemplate>
</DxPopup>
@* ── Save result popup (success + error) ── *@
<DxPopup @bind-Visible="_savePopupVisible"
HeaderText='@(_saveErrorMessage is null ? _saveSuccessHeader : L("Error", "Fehler"))'
Width="460px"
MaxWidth="95vw"
ShowFooter="true"
CloseOnOutsideClick="false"
ShowCloseButton="false"
CloseOnEscape="false">
<BodyContentTemplate>
@if (_saveErrorMessage is null)
{
<div style="display:flex;align-items:flex-start;gap:1rem;padding:0.5rem 0;">
<div style="flex-shrink:0;width:40px;height:40px;background:#d1fae5;border-radius:50%;
display:flex;align-items:center;justify-content:center;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#065f46" 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>
</div>
<div>
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
@_saveSuccessTitle
</p>
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;">
@_saveSuccessBody
</p>
</div>
</div>
}
else
{
<div style="display:flex;align-items:flex-start;gap:1rem;padding:0.5rem 0;">
<div style="flex-shrink:0;width:40px;height:40px;background:#fee2e2;border-radius:50%;
display:flex;align-items:center;justify-content:center;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#991b1b" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z" />
</svg>
</div>
<div>
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
@L("SenderEditor_ErrorCreatingEnvelope", "Fehler beim Erstellen des Umschlags")
</p>
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;word-break:break-word;">
@_saveErrorMessage
</p>
</div>
</div>
}
</BodyContentTemplate>
<FooterContentTemplate>
<div style="display:flex;gap:0.5rem;justify-content:flex-end;width:100%;padding:0.5rem 0;">
@if (_saveErrorMessage is null)
{
<button class="btn btn-outline-secondary"
@onclick="() => _savePopupVisible = false"
style="border-radius:6px;padding:0.5rem 1.25rem;font-weight:500;">
@L("Close", "Schließen")
</button>
@if (_saveNavigateToDashboard)
{
<button class="btn btn-primary"
@onclick="GoToDashboard"
style="background:linear-gradient(135deg,#059669,#047857);border:none;border-radius:6px;
padding:0.5rem 1.5rem;font-weight:600;color:#fff;">
@L("SenderEditor_ToOverview", "Zur Übersicht")
</button>
}
}
else
{
<button class="btn btn-outline-secondary"
@onclick="() => _savePopupVisible = false"
style="border-radius:6px;padding:0.5rem 1.25rem;font-weight:500;">
@L("Close", "Schließen")
</button>
}
</div>
</FooterContentTemplate>
</DxPopup>
@code {
[SupplyParameterFromQuery(Name = "envelopeId")]
public int? EnvelopeId { get; set; }
// ── Constants ──
// 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
// CssClass for JS click coordinate helper
const string ViewerCssClass = "sender-editor-pdf-viewer";
// ── State ──
bool _pdfLoaded = false;
bool _isPageReady;
bool _isPdfUploadInProgress;
string _fileName = string.Empty;
string? _errorMessage;
byte[]? _originalPdfBytes; // Pristine upload — never modified, used as base for redraw
int _pageCount;
List<PdfPageImageDto> _pagePreviews = [];
int? _loadedEnvelopeId;
int? _editingEnvelopeId;
bool _isLoadingEnvelopeForEdit;
List<SignatureFieldDraft> _signatureFields = [];
ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen"
IReadOnlyList<SenderPdfFieldOverlay> 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;
string? _saveErrorMessage = null;
string _saveSuccessHeader = "Erfolg";
string _saveSuccessTitle = "Vorgang erfolgreich abgeschlossen.";
string _saveSuccessBody = string.Empty;
bool _saveNavigateToDashboard = false;
// ── Envelope metadata ──
string _envelopeTitle = string.Empty;
string _envelopeMessage = string.Empty;
bool _titleTouched = false;
List<ReceiverDraft> _receivers = [];
bool _receiverPopupVisible;
string _receiverDraftName = string.Empty;
string _receiverDraftEmail = string.Empty;
string _receiverDraftPhoneNumber = string.Empty;
string? _selectedReceiverEmailSuggestion;
string? _receiverPopupValidationMessage;
bool _isReceiverEmailSearchRunning;
List<string> _receiverEmailSuggestions = [];
int _receiverEmailSearchVersion;
static readonly System.ComponentModel.DataAnnotations.EmailAddressAttribute ReceiverEmailValidator = new();
bool IsEditMode => EnvelopeId.HasValue && EnvelopeId.Value > 0;
// ── Lifecycle ──
protected override async Task OnParametersSetAsync()
{
if (EnvelopeId.HasValue && EnvelopeId.Value > 0)
_editingEnvelopeId = EnvelopeId.Value;
if (IsEditMode && _loadedEnvelopeId != EnvelopeId)
{
await LoadEnvelopeForEditAsync(EnvelopeId!.Value);
return;
}
if (IsEditMode)
return;
}
protected override Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_isPageReady = true;
StateHasChanged();
}
return Task.CompletedTask;
}
async Task LoadEnvelopeForEditAsync(int envelopeId)
{
if (_isLoadingEnvelopeForEdit)
return;
_isLoadingEnvelopeForEdit = true;
_errorMessage = null;
try
{
var envelopes = await EnvelopeService.GetAsync(id: envelopeId);
var envelope = envelopes?.FirstOrDefault()
?? throw new InvalidOperationException($"Envelope {envelopeId} not found.");
var document = await DocumentService.GetSenderDocumentDetailsByEnvelopeIdAsync(envelopeId);
_envelopeTitle = envelope.Title ?? string.Empty;
_envelopeMessage = envelope.Message ?? string.Empty;
var envelopeReceivers = envelope.EnvelopeReceivers?.ToList() ?? [];
_receivers = envelopeReceivers
.Select((er, index) => new ReceiverDraft(
Guid.NewGuid(),
er.Name ?? er.Receiver?.EmailAddress ?? $"Receiver {er.ReceiverId}",
er.Receiver?.EmailAddress ?? string.Empty,
string.Empty,
ReceiverPalette[index % ReceiverPalette.Length]))
.ToList();
var receiverById = envelopeReceivers.ToDictionary(er => er.ReceiverId, er => er);
var colorByEmail = _receivers
.Where(r => !string.IsNullOrWhiteSpace(r.Email))
.GroupBy(r => r.Email, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First().Color, StringComparer.OrdinalIgnoreCase);
_signatureFields = (document?.Elements ?? Enumerable.Empty<DocReceiverElementDto>())
.Select(element =>
{
receiverById.TryGetValue(element.ReceiverId, out var envelopeReceiver);
var receiverName = envelopeReceiver?.Name
?? envelopeReceiver?.Receiver?.EmailAddress
?? element.FullName
?? $"Receiver {element.ReceiverId}";
var receiverEmail = envelopeReceiver?.Receiver?.EmailAddress;
var color = (!string.IsNullOrWhiteSpace(receiverEmail) && colorByEmail.TryGetValue(receiverEmail, out var receiverColor))
? receiverColor
: ReceiverPalette[Math.Abs(element.ReceiverId) % ReceiverPalette.Length];
return new SignatureFieldDraft(
XPt: element.X * 72.0,
YPt: element.Y * 72.0,
Page: element.Page,
ReceiverName: receiverName,
Color: color);
})
.ToList();
_originalPdfBytes = document?.ByteData;
_pdfLoaded = _originalPdfBytes is { Length: > 0 };
_pagePreviews = [];
_pageCount = _pagePreviews.Count > 0
? _pagePreviews.Count
: EstimatePdfPageCount(_originalPdfBytes)
?? Math.Max(1, _signatureFields.Select(f => f.Page).DefaultIfEmpty(1).Max());
_fileName = $"Envelope-{envelopeId}.pdf";
_pendingReceiverForPlacement = null;
_loadedEnvelopeId = envelopeId;
_editingEnvelopeId = envelopeId;
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to load envelope {EnvelopeId} for edit mode", envelopeId);
_errorMessage = string.Format(
L("SenderEditor_LoadEnvelopeError", "Fehler beim Laden des Umschlags #{0}: {1}"),
envelopeId,
ex.Message);
}
finally
{
_isLoadingEnvelopeForEdit = false;
}
}
// ── PDF upload ──
async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e)
{
if (!_isPageReady)
return;
if (_isPdfUploadInProgress)
return;
_errorMessage = null;
var file = e.File;
if (file is null) return;
if (!file.Name.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
{
_errorMessage = L("SenderEditor_SelectPdf", "Bitte wählen Sie eine PDF-Datei aus.");
return;
}
try
{
_isPdfUploadInProgress = true;
await InvokeAsync(StateHasChanged);
const long maxBytes = 50 * 1024 * 1024;
using var ms = new System.IO.MemoryStream();
await file.OpenReadStream(maxBytes).CopyToAsync(ms);
_originalPdfBytes = ms.ToArray();
_fileName = file.Name;
_pdfLoaded = true;
_pagePreviews = (await PdfRenderService.RenderPagesAsync(_originalPdfBytes))
.OrderBy(p => p.PageNumber)
.ToList();
_pageCount = _pagePreviews.Count > 0
? _pagePreviews.Count
: EstimatePdfPageCount(_originalPdfBytes) ?? 1;
_signatureFields.Clear();
_pendingReceiverForPlacement = null;
Logger.LogInformation("PDF loaded: {Name} ({Size} bytes)", _fileName, _originalPdfBytes.Length);
}
catch (Exception ex)
{
_errorMessage = string.Format(
L("SenderEditor_LoadFileError", "Fehler beim Laden der Datei: {0}"),
ex.Message);
Logger.LogError(ex, "Failed to load PDF file");
}
finally
{
_isPdfUploadInProgress = false;
await InvokeAsync(StateHasChanged);
}
}
// ── Placement mode ──
void ActivatePlacementForReceiver(ReceiverDraft receiver)
{
// Toggle: clicking the same receiver again cancels placement
_pendingReceiverForPlacement = _pendingReceiverForPlacement?.Id == receiver.Id
? null
: receiver;
}
void CancelPlacement() => _pendingReceiverForPlacement = null;
// ── PDF area click → place field ──
async Task OnPdfAreaClickAsync(MouseEventArgs e)
{
if (_pendingReceiverForPlacement is null) return;
if (!_pdfLoaded || _originalPdfBytes is null) return;
// Ask JS for the normalised click position within the rendered PDF page
var coords = await JSRuntime.InvokeAsync<NormalisedCoords?>(
"envelopeEditor.getClickCoordsOnPdfPage",
ViewerCssClass, e.ClientX, e.ClientY);
if (coords is null)
{
Logger.LogWarning("[SenderEditor] getClickCoordsOnPdfPage returned null");
return;
}
int page1Based = coords.PageIndex + 1;
var pagePreview = _pagePreviews.FirstOrDefault(p => p.PageNumber == page1Based);
var pageWidthPt = pagePreview?.WidthPt ?? 595.0;
var pageHeightPt = pagePreview?.HeightPt ?? 842.0;
// Convert normalised [0,1] → PDF points; clamp so box stays inside page
double xPt = coords.NormX * pageWidthPt;
double yPt = coords.NormY * pageHeightPt;
xPt = Math.Max(0, Math.Min(xPt, pageWidthPt - SigWidthPt));
yPt = Math.Max(0, Math.Min(yPt, pageHeightPt - SigHeightPt));
var field = new SignatureFieldDraft(
XPt: xPt,
YPt: yPt,
Page: page1Based,
ReceiverName: _pendingReceiverForPlacement.FullName,
Color: _pendingReceiverForPlacement.Color);
_signatureFields.Add(field);
_pendingReceiverForPlacement = null;
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 OnRemoveFieldByIndexAsync(int index)
{
if (index < 0 || index >= _signatureFields.Count)
return;
_signatureFields.RemoveAt(index);
await Task.CompletedTask;
}
async Task OnMoveFieldAsync(SenderPdfFieldMoveRequest request)
{
if (request.Index < 0 || request.Index >= _signatureFields.Count)
return;
var field = _signatureFields[request.Index];
_signatureFields[request.Index] = field with
{
XPt = request.XPt,
YPt = request.YPt,
Page = request.Page
};
await Task.CompletedTask;
}
// ── Clear all fields ──
async Task ClearAllFieldsAsync()
{
_signatureFields.Clear();
_pendingReceiverForPlacement = null;
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()
{
NavigationManager.NavigateTo("/sender");
}
async Task SaveDraftAsync()
{
_isSaving = true;
_saveErrorMessage = null;
await InvokeAsync(StateHasChanged);
try
{
var previousEnvelopeId = EnvelopeId;
var command = BuildCreateEnvelopeCommand(send: false);
Logger.LogInformation("[SenderEditor] Saving draft. EnvelopeId={EnvelopeId} IsEditMode={IsEditMode}", command.EnvelopeId, IsEditMode);
var result = await EnvelopeService.UpsertAsync(command);
if (result is null)
throw new InvalidOperationException("Draft save did not return a result.");
EnvelopeId = result.Id;
_loadedEnvelopeId = result.Id;
_editingEnvelopeId = result.Id;
if (previousEnvelopeId != result.Id)
{
NavigationManager.NavigateTo($"/sender/editor?envelopeId={result.Id}", forceLoad: false);
}
NavigationManager.NavigateTo("/sender", forceLoad: false);
}
catch (Exception ex)
{
Logger.LogError(ex, "[SenderEditor] Failed to save draft");
_saveErrorMessage = ex.Message;
_savePopupVisible = true;
}
finally
{
_isSaving = false;
}
}
// ── Send envelope ──
async Task SendAsync()
{
// ── Validation ──
_titleTouched = true;
if (!_pdfLoaded || _originalPdfBytes is null)
{
_saveErrorMessage = L("SenderEditor_Validation_UploadPdf", "Bitte laden Sie zuerst ein PDF-Dokument hoch.");
_savePopupVisible = true;
return;
}
if (string.IsNullOrWhiteSpace(_envelopeTitle))
{
_saveErrorMessage = L("SenderEditor_Validation_TitleRequired", "Bitte geben Sie einen Titel ein.");
_savePopupVisible = true;
return;
}
if (_receivers.Count == 0)
{
_saveErrorMessage = L("SenderEditor_Validation_ReceiverRequired", "Bitte fügen Sie mindestens einen Empfänger hinzu.");
_savePopupVisible = true;
return;
}
if (_signatureFields.Count == 0)
{
_saveErrorMessage = L("SenderEditor_Validation_SignatureFieldRequired", "Bitte platzieren Sie mindestens ein Signaturfeld.");
_savePopupVisible = true;
return;
}
// Warn if any receiver has no signature field, but don't block
var receiversWithoutField = _receivers
.Where(r => !_signatureFields.Any(f => f.ReceiverName == r.FullName))
.ToList();
if (receiversWithoutField.Count > 0)
{
Logger.LogWarning(
"[SenderEditor] Receivers without signature field: {Names}",
string.Join(", ", receiversWithoutField.Select(r => r.FullName)));
}
_isSaving = true;
_saveErrorMessage = null;
await InvokeAsync(StateHasChanged);
try
{
var envelopeId = _editingEnvelopeId
?? EnvelopeId
?? _loadedEnvelopeId
?? GetEnvelopeIdFromCurrentUri();
if (!envelopeId.HasValue || envelopeId.Value <= 0)
{
var saveResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: false))
?? throw new InvalidOperationException("Draft save did not return a result.");
envelopeId = saveResult.Id;
EnvelopeId = saveResult.Id;
_loadedEnvelopeId = saveResult.Id;
_editingEnvelopeId = saveResult.Id;
}
var sendResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: true) with { EnvelopeId = envelopeId })
?? throw new InvalidOperationException("Send did not return a result.");
Logger.LogInformation("[SenderEditor] Envelope sent from draft. Id={Id} Status={Status}", sendResult.Id, sendResult.Status);
// Success — show popup; GoToDashboard clears cache and navigates
_saveErrorMessage = null;
_saveSuccessHeader = L("SenderEditor_Sent_Header", "Umschlag gesendet");
_saveSuccessTitle = L("SenderEditor_Sent_Title", "Umschlag wurde erfolgreich gesendet.");
_saveSuccessBody = L("SenderEditor_Sent_Body", "Der bestehende Entwurf wurde versendet.");
_saveNavigateToDashboard = true;
_savePopupVisible = true;
}
catch (Exception ex)
{
Logger.LogError(ex, "[SenderEditor] Failed to send envelope draft");
_saveErrorMessage = ex.Message;
_savePopupVisible = true;
}
finally
{
_isSaving = false;
}
}
CreateEnvelopeCommand BuildCreateEnvelopeCommand(bool send)
{
var envelopeId = _editingEnvelopeId
?? EnvelopeId
?? _loadedEnvelopeId
?? GetEnvelopeIdFromCurrentUri();
if (IsEditMode && envelopeId is null)
throw new InvalidOperationException("Edit mode envelope id is missing.");
var docBase64 = _originalPdfBytes is { Length: > 0 }
? Convert.ToBase64String(_originalPdfBytes)
: null;
var receiverCommands = _receivers.Select(receiver =>
{
var fields = _signatureFields
.Where(f => f.ReceiverName == receiver.FullName)
.Select(f => new CreateEnvelopeFieldDto(
X: f.XPt / 72.0,
Y: f.YPt / 72.0,
Page: f.Page))
.ToList();
return new CreateEnvelopeReceiverDto(
EmailAddress: receiver.Email,
Name: receiver.FullName,
PhoneNumber: string.IsNullOrWhiteSpace(receiver.PhoneNumber) ? null : receiver.PhoneNumber,
Fields: fields);
}).ToList();
return new CreateEnvelopeCommand
{
EnvelopeId = envelopeId,
Title = _envelopeTitle,
Message = _envelopeMessage,
TFAEnabled = false,
Send = send,
Document = string.IsNullOrWhiteSpace(docBase64) ? null : new CreateEnvelopeDocumentDto(docBase64),
Receivers = receiverCommands
};
}
int? GetEnvelopeIdFromCurrentUri()
{
try
{
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
if (string.IsNullOrWhiteSpace(uri.Query))
return null;
var query = QueryHelpers.ParseQuery(uri.Query);
if (!query.TryGetValue("envelopeId", out var raw))
return null;
return int.TryParse(raw.ToString(), out var id) && id > 0
? id
: null;
}
catch
{
return null;
}
}
// ── Receiver popup ──
void OpenAddReceiverPopup()
{
_receiverDraftName = string.Empty;
_receiverDraftEmail = string.Empty;
_receiverDraftPhoneNumber = string.Empty;
_selectedReceiverEmailSuggestion = null;
_receiverPopupValidationMessage = null;
_receiverEmailSuggestions.Clear();
_receiverPopupVisible = true;
}
void CloseAddReceiverPopup()
{
_receiverPopupVisible = false;
_receiverPopupValidationMessage = null;
_selectedReceiverEmailSuggestion = null;
_isReceiverEmailSearchRunning = false;
}
void OnReceiverNameChanged(string? value)
{
_receiverDraftName = value ?? string.Empty;
_receiverPopupValidationMessage = null;
}
void OnReceiverPhoneNumberChanged(string? value)
{
_receiverDraftPhoneNumber = value ?? string.Empty;
_receiverPopupValidationMessage = null;
}
async Task OnReceiverEmailKeyDownAsync(KeyboardEventArgs e)
{
if (_receiverEmailSuggestions.Count == 0)
{
if (e.Key == "Escape") _selectedReceiverEmailSuggestion = null;
return;
}
var currentIndex = _selectedReceiverEmailSuggestion is null
? -1
: _receiverEmailSuggestions.FindIndex(em =>
string.Equals(em, _selectedReceiverEmailSuggestion, StringComparison.OrdinalIgnoreCase));
if (e.Key == "ArrowDown")
{
var next = currentIndex < _receiverEmailSuggestions.Count - 1 ? currentIndex + 1 : 0;
SelectReceiverEmailSuggestion(_receiverEmailSuggestions[next]);
}
else if (e.Key == "ArrowUp")
{
var next = currentIndex > 0 ? currentIndex - 1 : _receiverEmailSuggestions.Count - 1;
SelectReceiverEmailSuggestion(_receiverEmailSuggestions[next]);
}
else if (e.Key == "Enter")
{
var sel = currentIndex >= 0 && currentIndex < _receiverEmailSuggestions.Count
? _receiverEmailSuggestions[currentIndex]
: _receiverEmailSuggestions.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(sel))
await OnReceiverEmailSuggestionCommittedAsync(sel);
}
else if (e.Key == "Escape")
{
_receiverEmailSuggestions.Clear();
_selectedReceiverEmailSuggestion = null;
}
}
void SelectReceiverEmailSuggestion(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return;
_selectedReceiverEmailSuggestion = value.Trim();
_receiverDraftEmail = _selectedReceiverEmailSuggestion;
_receiverPopupValidationMessage = null;
}
Task OnReceiverEmailSuggestionCommittedAsync(string? value)
{
SelectReceiverEmailSuggestion(value);
_receiverEmailSuggestions.Clear();
return Task.CompletedTask;
}
async Task OnReceiverEmailTextChangedAsync(string? value)
{
_receiverDraftEmail = value?.Trim() ?? string.Empty;
_selectedReceiverEmailSuggestion = _receiverDraftEmail;
_receiverPopupValidationMessage = null;
var searchVersion = ++_receiverEmailSearchVersion;
if (string.IsNullOrWhiteSpace(_receiverDraftEmail) || _receiverDraftEmail.Length < 2)
{
_receiverEmailSuggestions.Clear();
_selectedReceiverEmailSuggestion = null;
_isReceiverEmailSearchRunning = false;
return;
}
_isReceiverEmailSearchRunning = true;
try
{
var results = await ReceiverService.SearchReceiverEmailsAsync(_receiverDraftEmail);
if (searchVersion != _receiverEmailSearchVersion) return;
_receiverEmailSuggestions = results
.Where(em => !string.IsNullOrWhiteSpace(em))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(em => em)
.Take(12)
.ToList();
_selectedReceiverEmailSuggestion = _receiverEmailSuggestions.FirstOrDefault(em =>
string.Equals(em, _receiverDraftEmail, StringComparison.OrdinalIgnoreCase));
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to load receiver email suggestions for {SearchTerm}", _receiverDraftEmail);
if (searchVersion == _receiverEmailSearchVersion)
{
_receiverEmailSuggestions.Clear();
_selectedReceiverEmailSuggestion = null;
}
}
finally
{
if (searchVersion == _receiverEmailSearchVersion)
_isReceiverEmailSearchRunning = false;
}
}
Task SaveReceiverAsync()
{
var fullName = _receiverDraftName.Trim();
var email = _receiverDraftEmail.Trim();
var phoneNumber = _receiverDraftPhoneNumber.Trim();
if (string.IsNullOrWhiteSpace(fullName))
{
_receiverPopupValidationMessage = L("SenderEditor_Validation_FullNameRequired", "Bitte geben Sie einen Vor- und Nachnamen ein.");
return Task.CompletedTask;
}
if (string.IsNullOrWhiteSpace(email))
{
_receiverPopupValidationMessage = L("SenderEditor_Validation_EmailRequired", "Bitte geben Sie eine E-Mail-Adresse ein.");
return Task.CompletedTask;
}
if (!ReceiverEmailValidator.IsValid(email))
{
_receiverPopupValidationMessage = L("SenderEditor_Validation_EmailInvalid", "Bitte geben Sie eine gültige E-Mail-Adresse ein.");
return Task.CompletedTask;
}
if (_receivers.Any(r => string.Equals(r.Email, email, StringComparison.OrdinalIgnoreCase)))
{
_receiverPopupValidationMessage = L("SenderEditor_Validation_EmailDuplicate", "Diese E-Mail-Adresse wurde bereits hinzugefügt.");
return Task.CompletedTask;
}
_receivers.Add(new ReceiverDraft(Guid.NewGuid(), fullName, email, phoneNumber,
ReceiverPalette[_receivers.Count % ReceiverPalette.Length]));
CloseAddReceiverPopup();
return Task.CompletedTask;
}
// ── Models ──
record SignatureFieldDraft(double XPt, double YPt, int Page, string ReceiverName, string Color);
record NormalisedCoords(double NormX, double NormY, int PageIndex);
record ReceiverDraft(Guid Id, string FullName, string Email, string PhoneNumber, string Color);
// ── Receiver colour palette (cycles when > 8 receivers) ──
static readonly string[] ReceiverPalette =
[
"#4F46E5", // indigo
"#059669", // emerald
"#DC2626", // red
"#D97706", // amber
"#7C3AED", // violet
"#0891B2", // cyan
"#BE185D", // pink
"#65A30D", // lime
];
string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
}
}