Compare commits
11 Commits
master
...
feat/custo
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c81d79a9b | |||
| 696cd1b623 | |||
| 2434f67627 | |||
| 776700b247 | |||
| 7a114f47a6 | |||
| ed44d841e7 | |||
| 4bd0cc2e94 | |||
| 8a4963472c | |||
| 4d3f72a3ed | |||
| 1ebd43e3b0 | |||
| 19aeda5aef |
16
EnvelopeGenerator.Application/Common/Dto/PdfPageImageDto.cs
Normal file
16
EnvelopeGenerator.Application/Common/Dto/PdfPageImageDto.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
public sealed class PdfPageImageDto
|
||||
{
|
||||
public int PageNumber { get; init; }
|
||||
|
||||
public double WidthPt { get; init; }
|
||||
|
||||
public double HeightPt { get; init; }
|
||||
|
||||
public int WidthPx { get; init; }
|
||||
|
||||
public int HeightPx { get; init; }
|
||||
|
||||
public string ImageDataUrl { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
public sealed class PdfRenderRequestDto
|
||||
{
|
||||
public byte[]? PdfBytes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||
|
||||
public interface IPdfPageImageRenderer
|
||||
{
|
||||
Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsImagesAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -735,7 +735,9 @@
|
||||
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Fehler beim Laden der Datei: {0}</value></data>
|
||||
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Bitte unterzeichnen Sie das beigefügte Dokument.</value></data>
|
||||
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>Kein Dokument geladen</value></data>
|
||||
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>Keine Seiten zur Anzeige verfügbar.</value></data>
|
||||
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>Noch keine Empfänger hinzugefügt.</value></data>
|
||||
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Seite</value></data>
|
||||
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Signaturfeld platzieren</value></data>
|
||||
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Alle Signaturfelder entfernen</value></data>
|
||||
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Bitte wählen Sie eine PDF-Datei aus.</value></data>
|
||||
|
||||
@@ -735,7 +735,9 @@
|
||||
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Error loading file: {0}</value></data>
|
||||
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Please sign the attached document.</value></data>
|
||||
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>No document loaded</value></data>
|
||||
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>No pages to display.</value></data>
|
||||
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>No receivers added yet.</value></data>
|
||||
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Page</value></data>
|
||||
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Place signature field</value></data>
|
||||
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Remove all signature fields</value></data>
|
||||
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Please select a PDF file.</value></data>
|
||||
|
||||
@@ -735,7 +735,9 @@
|
||||
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Erreur lors du chargement du fichier : {0}</value></data>
|
||||
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Veuillez signer le document ci-joint.</value></data>
|
||||
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>Aucun document chargé</value></data>
|
||||
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>Aucune page à afficher.</value></data>
|
||||
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>Aucun destinataire ajouté pour le moment.</value></data>
|
||||
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Page</value></data>
|
||||
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Placer un champ de signature</value></data>
|
||||
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Supprimer tous les champs de signature</value></data>
|
||||
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Veuillez sélectionner un fichier PDF.</value></data>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DevExpress.Document.Processor" Version="25.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
57
EnvelopeGenerator.Infrastructure.Doc/PdfPageImageRenderer.cs
Normal file
57
EnvelopeGenerator.Infrastructure.Doc/PdfPageImageRenderer.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using DevExpress.Drawing;
|
||||
using DevExpress.Pdf;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Doc;
|
||||
|
||||
public class PdfPageImageRenderer : IPdfPageImageRenderer
|
||||
{
|
||||
private const float RenderDpi = 110f;
|
||||
|
||||
public Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsImagesAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pdfBytes);
|
||||
|
||||
if (pdfBytes.Length == 0)
|
||||
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(Array.Empty<PdfPageImageDto>());
|
||||
|
||||
using var stream = new MemoryStream(pdfBytes, writable: false);
|
||||
using var processor = new PdfDocumentProcessor();
|
||||
processor.LoadDocument(stream);
|
||||
|
||||
var pages = processor.Document.Pages;
|
||||
if (pages.Count == 0)
|
||||
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(Array.Empty<PdfPageImageDto>());
|
||||
|
||||
var renderOptions = PdfPageRenderingParameters.CreateWithResolution(RenderDpi);
|
||||
var results = new List<PdfPageImageDto>(pages.Count);
|
||||
|
||||
for (var i = 0; i < pages.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var pageNumber = i + 1;
|
||||
var page = pages[i];
|
||||
var cropBox = page.CropBox;
|
||||
|
||||
using DXBitmap bitmap = processor.CreateDXBitmap(pageNumber, renderOptions);
|
||||
using var imageStream = new MemoryStream();
|
||||
bitmap.Save(imageStream, DXImageFormat.Png);
|
||||
|
||||
var dataUrl = $"data:image/png;base64,{Convert.ToBase64String(imageStream.ToArray())}";
|
||||
|
||||
results.Add(new PdfPageImageDto
|
||||
{
|
||||
PageNumber = pageNumber,
|
||||
WidthPt = cropBox.Width,
|
||||
HeightPt = cropBox.Height,
|
||||
WidthPx = bitmap.Width,
|
||||
HeightPx = bitmap.Height,
|
||||
ImageDataUrl = dataUrl
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(results);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace EnvelopeGenerator.Server.Client.Components.PdfEditor;
|
||||
|
||||
public sealed record SenderPdfFieldMoveRequest(
|
||||
int Index,
|
||||
int Page,
|
||||
double XPt,
|
||||
double YPt);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace EnvelopeGenerator.Server.Client.Components.PdfEditor;
|
||||
|
||||
public sealed record SenderPdfFieldOverlay(
|
||||
int Index,
|
||||
int Page,
|
||||
double XPt,
|
||||
double YPt,
|
||||
string ReceiverName,
|
||||
string Color);
|
||||
@@ -0,0 +1,173 @@
|
||||
@using EnvelopeGenerator.Application.Resources
|
||||
@using EnvelopeGenerator.Application.Common.Dto
|
||||
@using Microsoft.Extensions.Localization
|
||||
@inject IStringLocalizer<Resource> UiLocalizer
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
<div class="pdf-editor-wrapper sender-image-editor sender-editor-pdf-viewer"
|
||||
style="cursor: @(IsPlacementMode ? "crosshair" : "default");"
|
||||
@onclick="HandleClickAsync">
|
||||
@if (PageCount <= 0)
|
||||
{
|
||||
<div class="sender-image-editor__empty">@L("SenderEditor_NoPagesToDisplay", "Keine Seiten zur Anzeige verfügbar.")</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (Pages.Count == 0 && !string.IsNullOrWhiteSpace(PdfDataUrl))
|
||||
{
|
||||
<div class="sender-image-editor__native-preview">
|
||||
<iframe class="sender-image-editor__native-frame"
|
||||
src="@PdfDataUrl"
|
||||
title="PDF Preview"></iframe>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="sender-image-editor__pages">
|
||||
@for (var pageIndex = 0; pageIndex < PageCount; pageIndex++)
|
||||
{
|
||||
var pagePreview = pageIndex < Pages.Count ? Pages[pageIndex] : null;
|
||||
var pageNumber = pagePreview?.PageNumber ?? (pageIndex + 1);
|
||||
var widthPt = pagePreview?.WidthPt ?? 595.0;
|
||||
var heightPt = pagePreview?.HeightPt ?? 842.0;
|
||||
|
||||
<div class="sender-image-editor__page-block"
|
||||
data-editor-page="@pageNumber"
|
||||
data-page-width-pt="@widthPt"
|
||||
data-page-height-pt="@heightPt">
|
||||
<div class="sender-image-editor__page-canvas"
|
||||
data-editor-page-canvas="@pageNumber"
|
||||
ondragover="event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';"
|
||||
ondragenter="event.preventDefault();"
|
||||
@ondrop:preventDefault="true"
|
||||
@ondrop:stopPropagation="true"
|
||||
@ondrop="args => OnPageDropAsync(args, pageNumber, widthPt, heightPt)">
|
||||
@if (!string.IsNullOrWhiteSpace(pagePreview?.ImageDataUrl))
|
||||
{
|
||||
<img class="sender-image-editor__page-image"
|
||||
src="@(pagePreview!.ImageDataUrl)"
|
||||
alt="PDF Page @pageNumber"
|
||||
draggable="false" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="sender-image-editor__page-placeholder">@L("SenderEditor_PageLabel", "Seite") @pageNumber</div>
|
||||
}
|
||||
|
||||
@foreach (var field in Fields.Where(f => f.Page == pageNumber))
|
||||
{
|
||||
<div class="sender-image-editor__field"
|
||||
style="@GetFieldStyle(field, widthPt, heightPt)"
|
||||
title="@field.ReceiverName"
|
||||
draggable="true"
|
||||
@onmousedown="() => OnFieldDragStart(field.Index)"
|
||||
ondragstart="event.dataTransfer.setData('text/plain', 'field'); event.dataTransfer.effectAllowed = 'move';"
|
||||
@onclick:stopPropagation="true">
|
||||
<span class="sender-image-editor__field-label">@field.ReceiverName</span>
|
||||
<button type="button"
|
||||
class="sender-image-editor__field-remove"
|
||||
title='@L("Delete", "Löschen")'
|
||||
draggable="false"
|
||||
@onclick:stopPropagation="true"
|
||||
@onclick="() => RemoveField.InvokeAsync(field.Index)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public bool IsPlacementMode { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public int PageCount { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<PdfPageImageDto> Pages { get; set; } = Array.Empty<PdfPageImageDto>();
|
||||
|
||||
[Parameter]
|
||||
public string? PdfDataUrl { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<SenderPdfFieldOverlay> Fields { get; set; } = Array.Empty<SenderPdfFieldOverlay>();
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<MouseEventArgs> EditorClick { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<int> RemoveField { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<SenderPdfFieldMoveRequest> MoveField { get; set; }
|
||||
|
||||
private int? _draggedFieldIndex;
|
||||
|
||||
private static string GetFieldStyle(SenderPdfFieldOverlay field, double pageWidthPt, double pageHeightPt)
|
||||
{
|
||||
var leftPercent = (field.XPt / pageWidthPt) * 100.0;
|
||||
var topPercent = (field.YPt / pageHeightPt) * 100.0;
|
||||
var widthPercent = (127.44 / pageWidthPt) * 100.0;
|
||||
var heightPercent = (141.12 / pageHeightPt) * 100.0;
|
||||
|
||||
return string.Join(' ',
|
||||
$"left:{leftPercent:F4}%;",
|
||||
$"top:{topPercent:F4}%;",
|
||||
$"width:{widthPercent:F4}%;",
|
||||
$"height:{heightPercent:F4}%;",
|
||||
$"border-color:{field.Color};",
|
||||
$"background-color:{field.Color}22;");
|
||||
}
|
||||
|
||||
private async Task HandleClickAsync(MouseEventArgs args)
|
||||
{
|
||||
if (EditorClick.HasDelegate)
|
||||
await EditorClick.InvokeAsync(args);
|
||||
}
|
||||
|
||||
private void OnFieldDragStart(int fieldIndex)
|
||||
{
|
||||
_draggedFieldIndex = fieldIndex;
|
||||
}
|
||||
|
||||
private async Task OnPageDropAsync(DragEventArgs args, int pageNumber, double pageWidthPt, double pageHeightPt)
|
||||
{
|
||||
if (_draggedFieldIndex is null || !MoveField.HasDelegate)
|
||||
return;
|
||||
|
||||
var coords = await JSRuntime.InvokeAsync<DropCoords?>(
|
||||
"envelopeEditor.getDropCoordsOnPage",
|
||||
"sender-editor-pdf-viewer",
|
||||
pageNumber,
|
||||
args.ClientX,
|
||||
args.ClientY);
|
||||
|
||||
if (coords is null)
|
||||
return;
|
||||
|
||||
const double sigWidthPt = 1.77 * 72;
|
||||
const double sigHeightPt = 1.96 * 72;
|
||||
|
||||
var xPt = coords.NormX * pageWidthPt;
|
||||
var yPt = coords.NormY * pageHeightPt;
|
||||
xPt = Math.Max(0, Math.Min(xPt, pageWidthPt - sigWidthPt));
|
||||
yPt = Math.Max(0, Math.Min(yPt, pageHeightPt - sigHeightPt));
|
||||
|
||||
await MoveField.InvokeAsync(new SenderPdfFieldMoveRequest(_draggedFieldIndex.Value, pageNumber, xPt, yPt));
|
||||
_draggedFieldIndex = null;
|
||||
}
|
||||
|
||||
private sealed record DropCoords(double NormX, double NormY);
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
var value = UiLocalizer[key];
|
||||
return value.ResourceNotFound ? fallback : value.Value;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,8 @@ builder.Services.AddScoped<SignatureCacheService>();
|
||||
builder.Services.AddSingleton<AppVersionService>();
|
||||
builder.Services.AddScoped<EnvelopeService>();
|
||||
builder.Services.AddScoped<LocalizationService>();
|
||||
builder.Services.AddScoped<ReceiverService>();
|
||||
builder.Services.AddScoped<PdfRenderService>();
|
||||
|
||||
// UI Preferences & Theme
|
||||
builder.Services.AddScoped<UserPreferencesService>();
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
public class DocumentService(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the PDF bytes for the given envelope key from the API.
|
||||
@@ -29,4 +33,26 @@ public class DocumentService(IHttpClientFactory httpClientFactory)
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync(cancel);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches sender document details (including signature elements) by envelope id.
|
||||
/// Uses GET /api/Document?EnvelopeId={envelopeId}.
|
||||
/// </summary>
|
||||
public async Task<DocumentDto?> GetSenderDocumentDetailsByEnvelopeIdAsync(int envelopeId, CancellationToken cancel = default)
|
||||
{
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var response = await http.GetAsync($"/api/Document?EnvelopeId={envelopeId}", cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
|
||||
throw new HttpRequestException(
|
||||
$"Failed to load sender document details. Status: {statusCode} ({reasonPhrase})",
|
||||
null,
|
||||
response.StatusCode);
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<DocumentDto>(_jsonOptions, cancel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
public class PdfRenderService(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsync(byte[] pdfBytes, CancellationToken cancel = default)
|
||||
{
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
using var response = await http.PostAsJsonAsync(
|
||||
"/api/PdfRender/pages",
|
||||
new PdfRenderRequestDto { PdfBytes = pdfBytes },
|
||||
_jsonOptions,
|
||||
cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
|
||||
throw new HttpRequestException(
|
||||
$"Failed to render PDF pages. Status: {statusCode} ({reasonPhrase})",
|
||||
null,
|
||||
response.StatusCode);
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<List<PdfPageImageDto>>(_jsonOptions, cancel) ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
public class ReceiverService(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches receiver email suggestions from sender-authorized API.
|
||||
/// Calls GET /api/Receiver?EmailAddressSearch={term}&onlyEmailAddress=true.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<string>> SearchReceiverEmailsAsync(string searchTerm, CancellationToken cancel = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchTerm))
|
||||
return [];
|
||||
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var url = $"/api/Receiver?EmailAddressSearch={Uri.EscapeDataString(searchTerm)}&onlyEmailAddress=true";
|
||||
var response = await http.GetAsync(url, cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return [];
|
||||
|
||||
var emails = await response.Content.ReadFromJsonAsync<List<string>>(JsonOptions, cancel);
|
||||
return emails ?? [];
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@using EnvelopeGenerator.Server.Client.Models
|
||||
@using EnvelopeGenerator.Server.Client.Options
|
||||
@using EnvelopeGenerator.Server.Client.Components.PdfEditor
|
||||
@using DevExpress.Blazor
|
||||
@using DevExpress.Blazor.PdfViewer
|
||||
@using DevExpress.Blazor.Reporting
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,114 +0,0 @@
|
||||
@page "/envelope/DxPdfViewer"
|
||||
@rendermode InteractiveServer
|
||||
@using System.IO
|
||||
@using DevExpress.Blazor
|
||||
@using System.Reflection
|
||||
@using DevExpress.Blazor.PdfViewer
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
|
||||
<style>
|
||||
.custom-drop-zone {
|
||||
padding: 0 !important;
|
||||
border-style: dashed;
|
||||
border-width: 2px !important;
|
||||
height: 230px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(183, 183, 183, 0.1);
|
||||
}
|
||||
|
||||
.custom-drop-zone.custom-drop-zone-hover {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.custom-drop-zone svg {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.custom-drop-zone > *:not(#overviewDemoSelectButton) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pdf-viewer {
|
||||
height: 800px !important;
|
||||
min-height: 800px !important;
|
||||
}
|
||||
|
||||
.pdf-viewer .dxbrv-surface-wrapper,
|
||||
.pdf-viewer .dxbrv-document-surface {
|
||||
height: 100% !important;
|
||||
min-height: 750px !important;
|
||||
}
|
||||
|
||||
.pdf-viewer .dxbrv-report-preview-content {
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
min-width: 200px !important;
|
||||
min-height: 200px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="overviewDemoDropZone" class="card custom-drop-zone rounded-3 w-100 m-0">
|
||||
<span class="drop-file-icon mb-3"></span>
|
||||
<span class="drop-file-label">Drag and Drop File Here</span><span class="m-1">or</span>
|
||||
<DxButton Id="overviewDemoSelectButton"
|
||||
CssClass="m-1"
|
||||
RenderStyle="ButtonRenderStyle.Primary"
|
||||
Text="Select File" />
|
||||
</div>
|
||||
<DxFileInput @ref="fileInput"
|
||||
AcceptedFileTypes="@ALLOWED_FILE_TYPES"
|
||||
AllowedFileExtensions="@ALLOWED_FILE_TYPES"
|
||||
CssClass="w-100"
|
||||
ExternalDropZoneCssSelector="#overviewDemoDropZone"
|
||||
ExternalDropZoneDragOverCssClass="custom-drop-zone-hover"
|
||||
ExternalSelectButtonCssSelector="#overviewDemoSelectButton"
|
||||
FilesUploading="OnFilesUploading"
|
||||
MaxFileSize="2000000">
|
||||
</DxFileInput>
|
||||
|
||||
@if (DocumentContent != null && DocumentContent.Length > 0)
|
||||
{
|
||||
<div class="alert alert-success mt-3">
|
||||
PDF loaded: @DocumentContent.Length bytes
|
||||
</div>
|
||||
<DxPdfViewer CssClass="w-100 pdf-viewer" DocumentContent="@DocumentContent" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-info mt-3">
|
||||
Please upload a PDF file to view it.
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
readonly List<string> ALLOWED_FILE_TYPES = new List<string> { ".pdf" };
|
||||
DxFileInput fileInput;
|
||||
byte[] DocumentContent { get; set; }
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Assembly assembly = Assembly.GetExecutingAssembly();
|
||||
Stream stream = assembly.GetManifestResourceStream("EnvelopeGenerator.Server.Resources.Invoice.pdf");
|
||||
if (stream != null)
|
||||
{
|
||||
using (stream)
|
||||
using (var binaryReader = new BinaryReader(stream))
|
||||
DocumentContent = binaryReader.ReadBytes((int)stream.Length);
|
||||
}
|
||||
}
|
||||
protected async Task OnFilesUploading(FilesUploadingEventArgs args)
|
||||
{
|
||||
using (MemoryStream stream = new MemoryStream())
|
||||
{
|
||||
IFileInputSelectedFile file = args.Files[0];
|
||||
await file.OpenReadStream(file.Size).CopyToAsync(stream);
|
||||
DocumentContent = stream.ToArray();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
@page "/envelope/{EnvelopeKey}/DxReportViewer"
|
||||
@rendermode InteractiveServer
|
||||
@using XtraReport = DevExpress.XtraReports.UI.XtraReport
|
||||
@using DevExpress.Blazor.Reporting
|
||||
@using Microsoft.Extensions.Options
|
||||
@using EnvelopeGenerator.Server.Client.Options
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@inject InMemoryReportStorageWebExtension ReportStorage
|
||||
@inject DocumentService DocumentService
|
||||
@inject IOptions<ApiOptions> AppOptions
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
<link href="_content/DevExpress.Blazor.Reporting.Viewer/css/dx-blazor-reporting-components.bs5.css" rel="stylesheet" />
|
||||
|
||||
|
||||
@if (_report is not null) {
|
||||
<DxReportViewer Report="_report" RootCssClasses="w-100 h-100" Zoom="1.3" />
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string EnvelopeKey { get; init; } = null!;
|
||||
|
||||
XtraReport? _report = null;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_report = await CreateReport();
|
||||
}
|
||||
|
||||
async Task<XtraReport> CreateReport()
|
||||
{
|
||||
if (AppOptions.Value.UsePredefinedReports)
|
||||
{
|
||||
return Client.PredefinedReports.ReportsFactory.GetReport("LargeDatasetReport");
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
var pdfBytes = await DocumentService.GetDocumentAsync(EnvelopeKey);
|
||||
if (pdfBytes is null || pdfBytes.Length == 0)
|
||||
throw new InvalidOperationException($"No PDF bytes found for EnvelopeKey: {EnvelopeKey}");
|
||||
|
||||
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 });
|
||||
return report;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
@page "/envelope/Embed"
|
||||
@rendermode InteractiveServer
|
||||
@using System.IO
|
||||
@using DevExpress.Blazor
|
||||
@using System.Reflection
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
|
||||
<style>
|
||||
.custom-drop-zone {
|
||||
padding: 0 !important;
|
||||
border-style: dashed;
|
||||
border-width: 2px !important;
|
||||
height: 230px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(183, 183, 183, 0.1);
|
||||
}
|
||||
|
||||
.custom-drop-zone.custom-drop-zone-hover {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.custom-drop-zone svg {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.custom-drop-zone > *:not(#overviewDemoSelectButton) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pdf-viewer {
|
||||
height: 800px !important;
|
||||
min-height: 800px !important;
|
||||
}
|
||||
|
||||
.pdf-viewer .dxbrv-surface-wrapper,
|
||||
.pdf-viewer .dxbrv-document-surface {
|
||||
height: 100% !important;
|
||||
min-height: 750px !important;
|
||||
}
|
||||
|
||||
.pdf-viewer .dxbrv-report-preview-content {
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
min-width: 200px !important;
|
||||
min-height: 200px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="overviewDemoDropZone" class="card custom-drop-zone rounded-3 w-100 m-0">
|
||||
<span class="drop-file-icon mb-3"></span>
|
||||
<span class="drop-file-label">Drag and Drop File Here</span><span class="m-1">or</span>
|
||||
<DxButton Id="overviewDemoSelectButton"
|
||||
CssClass="m-1"
|
||||
RenderStyle="ButtonRenderStyle.Primary"
|
||||
Text="Select File" />
|
||||
</div>
|
||||
<DxFileInput @ref="fileInput"
|
||||
AcceptedFileTypes="@ALLOWED_FILE_TYPES"
|
||||
AllowedFileExtensions="@ALLOWED_FILE_TYPES"
|
||||
CssClass="w-100"
|
||||
ExternalDropZoneCssSelector="#overviewDemoDropZone"
|
||||
ExternalDropZoneDragOverCssClass="custom-drop-zone-hover"
|
||||
ExternalSelectButtonCssSelector="#overviewDemoSelectButton"
|
||||
FilesUploading="OnFilesUploading"
|
||||
MaxFileSize="2000000">
|
||||
</DxFileInput>
|
||||
|
||||
@if (DocumentContent != null && DocumentContent.Length > 0)
|
||||
{
|
||||
<div class="alert alert-success mt-3">
|
||||
PDF loaded: @DocumentContent.Length bytes
|
||||
</div>
|
||||
<embed src="@GetPdfDataUrl()" type="application/pdf" class="w-100 pdf-viewer" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-info mt-3">
|
||||
Please upload a PDF file to view it.
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
readonly List<string> ALLOWED_FILE_TYPES = new List<string> { ".pdf" };
|
||||
DxFileInput fileInput;
|
||||
byte[] DocumentContent { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Assembly assembly = Assembly.GetExecutingAssembly();
|
||||
Stream stream = assembly.GetManifestResourceStream("EnvelopeGenerator.Server.Resources.Invoice.pdf");
|
||||
if (stream != null)
|
||||
{
|
||||
using (stream)
|
||||
using (var binaryReader = new BinaryReader(stream))
|
||||
DocumentContent = binaryReader.ReadBytes((int)stream.Length);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task OnFilesUploading(FilesUploadingEventArgs args)
|
||||
{
|
||||
using (MemoryStream stream = new MemoryStream())
|
||||
{
|
||||
IFileInputSelectedFile file = args.Files[0];
|
||||
await file.OpenReadStream(file.Size).CopyToAsync(stream);
|
||||
DocumentContent = stream.ToArray();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetPdfDataUrl()
|
||||
{
|
||||
if (DocumentContent == null || DocumentContent.Length == 0)
|
||||
return string.Empty;
|
||||
|
||||
string base64 = Convert.ToBase64String(DocumentContent);
|
||||
return $"data:application/pdf;base64,{base64}#toolbar=0&navpanes=0&scrollbar=1";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using DigitalData.Auth.Claims;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class PdfRenderController(IPdfPageImageRenderer renderer) : ControllerBase
|
||||
{
|
||||
[HttpPost("pages")]
|
||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||
public async Task<IActionResult> RenderPages([FromBody] PdfRenderRequestDto? request, CancellationToken cancel)
|
||||
{
|
||||
if (request?.PdfBytes is not { Length: > 0 } bytes)
|
||||
return BadRequest("Missing PDF bytes.");
|
||||
|
||||
IReadOnlyList<PdfPageImageDto> pages = await renderer.RenderPagesAsImagesAsync(bytes, cancel);
|
||||
return Ok(pages);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
<ProjectReference Include="..\EnvelopeGenerator.Server.Client\EnvelopeGenerator.Server.Client.csproj" />
|
||||
<ProjectReference Include="..\..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
|
||||
<ProjectReference Include="..\..\EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\EnvelopeGenerator.Infrastructure.Doc\EnvelopeGenerator.Infrastructure.Doc.csproj" />
|
||||
<ProjectReference Include="..\..\EnvelopeGenerator.Infrastructure\EnvelopeGenerator.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\EnvelopeGenerator.PdfEditor\EnvelopeGenerator.PdfEditor.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -26,6 +26,8 @@ using NLog;
|
||||
using DigitalData.Auth.Claims;
|
||||
using EnvelopeGenerator.Server;
|
||||
using DigitalData.EmailProfilerDispatcher;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||
using EnvelopeGenerator.Infrastructure.Doc;
|
||||
|
||||
var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
|
||||
logger.Info("EnvelopeGenerator.Server logging initialized!");
|
||||
@@ -341,6 +343,8 @@ try
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ThemeService>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CustomImagesService>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.LocalizationService>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ReceiverService>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.PdfRenderService>();
|
||||
|
||||
// Business Services (Server specific)
|
||||
builder.Services.AddScoped<DocumentService>();
|
||||
@@ -349,6 +353,7 @@ try
|
||||
builder.Services.AddScoped<EnvelopeReceiverService>();
|
||||
builder.Services.AddScoped<SignatureService>();
|
||||
builder.Services.AddScoped<SignatureCacheService>();
|
||||
builder.Services.AddScoped<IPdfPageImageRenderer, PdfPageImageRenderer>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Application.Common.Interfaces.Services.IEnvelopeMailService, EnvelopeGenerator.Application.Services.EnvelopeMailService>();
|
||||
builder.Services.AddSingleton<AppVersionService>();
|
||||
|
||||
|
||||
@@ -56,6 +56,138 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sender-image-editor {
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.sender-image-editor__pages {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.sender-image-editor__native-preview {
|
||||
max-width: 980px;
|
||||
margin: 0 auto 1rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.sender-image-editor__native-frame {
|
||||
width: 100%;
|
||||
min-height: 70vh;
|
||||
border: 0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.sender-image-editor__page-block {
|
||||
background: #e2e8f0;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.sender-image-editor__page-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 595 / 842;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.sender-image-editor__page-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sender-image-editor__page-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.01em;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
}
|
||||
|
||||
.sender-image-editor__field {
|
||||
position: absolute;
|
||||
border: 2px solid;
|
||||
border-radius: 8px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
padding: 0.2rem 0.3rem;
|
||||
text-align: left;
|
||||
line-height: 1.1;
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.35rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sender-image-editor__field:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.sender-image-editor__field-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sender-image-editor__field-remove {
|
||||
border: none;
|
||||
background: rgba(15, 23, 42, 0.78);
|
||||
color: #ffffff;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
min-width: 1.1rem;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sender-image-editor__field-remove:hover {
|
||||
background: rgba(220, 38, 38, 0.9);
|
||||
}
|
||||
|
||||
.sender-image-editor__field:hover {
|
||||
filter: brightness(0.96);
|
||||
}
|
||||
|
||||
.sender-image-editor__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 320px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sender-editor-pdf-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -25,9 +25,10 @@ window.envelopeEditor = {
|
||||
}
|
||||
|
||||
// --- Candidate page elements (ordered by preference) ---
|
||||
// DxPdfViewer renders individual pages as .dxbl-pdfv-page elements.
|
||||
// DxReportViewer uses .dxbrv-report-preview-content-img as fallback.
|
||||
// Sender editor custom renderer uses data-editor-page blocks.
|
||||
// Legacy Dx viewers remain as fallback selectors.
|
||||
const pageSelectors = [
|
||||
'[data-editor-page]',
|
||||
'.dxbl-pdfv-page',
|
||||
'.dxbrv-report-preview-page',
|
||||
'.dxbrv-report-preview-content-img',
|
||||
@@ -91,5 +92,31 @@ window.envelopeEditor = {
|
||||
normY: normY,
|
||||
pageIndex: targetIndex
|
||||
};
|
||||
},
|
||||
|
||||
getDropCoordsOnPage: function (viewerCssClass, pageNumber, clientX, clientY) {
|
||||
const viewer = document.querySelector('.' + viewerCssClass);
|
||||
if (!viewer) {
|
||||
console.warn('[envelopeEditor] viewer not found for class:', viewerCssClass);
|
||||
return null;
|
||||
}
|
||||
|
||||
const page = viewer.querySelector('[data-editor-page="' + pageNumber + '"]');
|
||||
if (!page) {
|
||||
console.warn('[envelopeEditor] page not found:', pageNumber);
|
||||
return null;
|
||||
}
|
||||
|
||||
const rect = page.getBoundingClientRect();
|
||||
const clampedX = Math.max(rect.left, Math.min(clientX, rect.right));
|
||||
const clampedY = Math.max(rect.top, Math.min(clientY, rect.bottom));
|
||||
|
||||
const normX = (clampedX - rect.left) / rect.width;
|
||||
const normY = (clampedY - rect.top) / rect.height;
|
||||
|
||||
return {
|
||||
normX: normX,
|
||||
normY: normY
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -41,6 +41,7 @@ EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server", "{BF1700D5-592E-4FFA-84E8-5480E289A1F0}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
EnvelopeGenerator.Server\publish.bat = EnvelopeGenerator.Server\publish.bat
|
||||
SENDER_SAVE_EDIT_MIGRATION_PLAN.md = SENDER_SAVE_EDIT_MIGRATION_PLAN.md
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server\EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj", "{4E6C54DA-576D-0955-2564-9EC890BB8279}"
|
||||
@@ -51,6 +52,8 @@ Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "EnvelopeGenerator.Service_l
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Service", "EnvelopeGenerator.Service\EnvelopeGenerator.Service.csproj", "{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Infrastructure.Doc", "EnvelopeGenerator.Infrastructure.Doc\EnvelopeGenerator.Infrastructure.Doc.csproj", "{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -113,6 +116,10 @@ Global
|
||||
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -136,6 +143,7 @@ Global
|
||||
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}
|
||||
{83ED2617-B398-4859-8F59-B38F8807E83E} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
|
||||
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
||||
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {73E60370-756D-45AD-A19A-C40A02DACCC7}
|
||||
|
||||
217
SENDER_EDITOR_WASM_PDF_EDITOR_IMPLEMENTATION_PLAN_en.md
Normal file
217
SENDER_EDITOR_WASM_PDF_EDITOR_IMPLEMENTATION_PLAN_en.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# Sender Editor PDF Component Migration Plan
|
||||
|
||||
This plan is prepared to remove the DevExpress PDF viewer dependency from `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderEditorPage.razor` and move to a new WASM-based PDF editor component while preserving the current page design.
|
||||
|
||||
## 0) Scope and Non-Negotiable Rules
|
||||
|
||||
- **Scope**: Only the PDF editor/viewer area will change; the page action bar, popups, receiver management, and overall visual language must remain intact.
|
||||
- **Render mode target**: The `/sender/editor` flow will be implemented in `EnvelopeGenerator.Server.Client` and run in WebAssembly.
|
||||
- **Data compatibility**: The final `CreateEnvelopeCommand` payload shape, field units (`X`, `Y` in inches), receiver-field mapping, and save/send behavior must stay unchanged.
|
||||
- **To be removed**: `esid` query param + `IMemoryCache`-based editor session persistence will be removed completely.
|
||||
- **Library constraint**: `pdf.js` and DevExpress PDF viewer will not be used. For PDF rasterization, first verify whether DevExpress provides a suitable capability; if not, use a reliable, popular MIT-licensed alternative.
|
||||
- **Server-side pattern rule**: All new server-side work must follow the existing architecture; **Clean Architecture layers** and **MediatR pattern** must be preserved.
|
||||
- **Language rule (code side)**: All code and any code comments must always be in English.
|
||||
- **Localization rule**: UI text must not be hardcoded; use `IStringLocalizer<Resource>`. For every new key, add German, English, and French translations in `EnvelopeGenerator.Application/Resources/Resource.de-DE.resx`, `EnvelopeGenerator.Application/Resources/Resource.en-US.resx`, and `EnvelopeGenerator.Application/Resources/Resource.fr-FR.resx`.
|
||||
|
||||
---
|
||||
|
||||
## 1) Current Behavior Inventory (Parity Baseline)
|
||||
|
||||
**Goal:** Lock down all behaviors that must not break after migration.
|
||||
|
||||
**Tasks**
|
||||
- List the current editor flow:
|
||||
- PDF upload,
|
||||
- receiver add,
|
||||
- signature field placement,
|
||||
- field clearing,
|
||||
- draft save,
|
||||
- send.
|
||||
- Clarify current coordinate conversion:
|
||||
- Viewer click -> PDF points,
|
||||
- Save payload -> inches (`pt / 72`).
|
||||
- Capture current validation messages and popup behavior.
|
||||
- During this inventory phase, continuously add findings into this markdown under the relevant sections so it remains a living document.
|
||||
|
||||
**Output**
|
||||
- A short “parity checklist” is prepared and used throughout development.
|
||||
|
||||
---
|
||||
|
||||
## 2) Architecture Decisions and Packaging
|
||||
|
||||
**Goal:** Define technical boundaries of the new solution.
|
||||
|
||||
**Tasks**
|
||||
- Define the new reusable component:
|
||||
- Suggested name: `SenderPdfImageEditor`.
|
||||
- Location: `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/`.
|
||||
- Define PDF rasterization strategy:
|
||||
- Convert PDF -> image pages on the server side.
|
||||
- First confirm whether this conversion is possible with DevExpress.
|
||||
- If DevExpress does not provide a suitable solution, choose a popular MIT-licensed engine (e.g., `Docnet.Core` / PDFium-based).
|
||||
- Document license validation and production suitability.
|
||||
- Draft API contract:
|
||||
- endpoint(s) returning page images after PDF upload/read,
|
||||
- page metadata (width/height in pt),
|
||||
- optional re-raster endpoint for higher zoom levels.
|
||||
|
||||
**Output**
|
||||
- “Component contract + API contract” is finalized.
|
||||
|
||||
---
|
||||
|
||||
## 3) Copy `/sender/editor` to WASM Page
|
||||
|
||||
**Goal:** Run the editor in `Server.Client` using SPA behavior.
|
||||
|
||||
**Tasks**
|
||||
- Create a new page:
|
||||
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/EnvelopeSenderEditorPage.razor`
|
||||
- Temporary route: `/sender/editor2`
|
||||
- Keep the existing server-side `/sender/editor` page during development; both pages run in parallel.
|
||||
- Copy existing toolbar/receiver/popup markup as-is as much as possible.
|
||||
- Integrate client services (`EnvelopeService`, auth checks, receiver suggestion calls).
|
||||
|
||||
**Output**
|
||||
- WASM page is accessible, old UI layout is preserved, and PDF area works with a placeholder component.
|
||||
|
||||
---
|
||||
|
||||
## 4) Page-by-Page PDF to Image Conversion Service (With Priority Order)
|
||||
|
||||
**Goal:** Convert PDF into page-image list with near-identical visual fidelity.
|
||||
|
||||
**Tasks**
|
||||
- Add new application/service layer:
|
||||
- PDF bytes input,
|
||||
- raster output per page (PNG preferred for stable quality/transparency).
|
||||
- Evaluate client-side (WASM) rasterization feasibility if it is possible and does not hurt performance.
|
||||
- Priority order:
|
||||
1. DevExpress feasibility,
|
||||
2. If not suitable, MIT-licensed alternative,
|
||||
3. If needed, client-side rasterization option.
|
||||
- Return metadata:
|
||||
- page index,
|
||||
- image URL or base64,
|
||||
- page width/height (pt),
|
||||
- native image size in px.
|
||||
- Performance:
|
||||
- reasonable initial DPI (e.g., 144),
|
||||
- lazy/high-res render option for higher zoom levels.
|
||||
|
||||
**Output**
|
||||
- “PDF -> image pages + page metrics” endpoint/service is functional.
|
||||
|
||||
---
|
||||
|
||||
## 5) New Image-based PDF Viewer Features
|
||||
|
||||
**Goal:** Provide viewer UX similar to DevExpress viewer.
|
||||
|
||||
**Tasks**
|
||||
- Implement two display modes in `SenderPdfImageEditor`:
|
||||
- **Continuous mode**: pages stacked vertically,
|
||||
- **Single-page mode**: one page with next/prev navigation.
|
||||
- Add zoom controls:
|
||||
- zoom in/out,
|
||||
- fit width,
|
||||
- fit page.
|
||||
- Add page navigation:
|
||||
- page selector (n / total),
|
||||
- keyboard shortcuts (optional, phase 2).
|
||||
- Preserve UI consistency:
|
||||
- keep current sender editor color language and button style.
|
||||
|
||||
**Output**
|
||||
- Image-based viewer is fully functional.
|
||||
|
||||
---
|
||||
|
||||
## 6) Signature Field Management via DOM Overlay
|
||||
|
||||
**Goal:** Manage signature fields in a movable DOM layer instead of burning them into PDF bytes.
|
||||
|
||||
**Tasks**
|
||||
- Place an overlay layer on top of each page image.
|
||||
- Signature box lifecycle:
|
||||
- add (by click),
|
||||
- drag (move),
|
||||
- remove.
|
||||
- Normalize all interactions as page-relative coordinates.
|
||||
- Rendering:
|
||||
- receiver color,
|
||||
- receiver name,
|
||||
- box/badge style close to current placeholder visuals.
|
||||
- Preserve multi-receiver / multi-field support and receiver-field linkage.
|
||||
|
||||
**Output**
|
||||
- Users can move and delete each signature field independently.
|
||||
|
||||
---
|
||||
|
||||
## 7) Coordinate and Payload Parity (Critical)
|
||||
|
||||
**Goal:** Keep backend data type/unit behavior exactly unchanged.
|
||||
|
||||
**Tasks**
|
||||
- Convert overlay coordinates -> PDF points:
|
||||
- `xPt = normalizedX * pageWidthPt`
|
||||
- `yPt = normalizedY * pageHeightPt`
|
||||
- Keep points -> inches conversion on save/send:
|
||||
- `X = xPt / 72`, `Y = yPt / 72`.
|
||||
- Use the same DTO shape in `CreateEnvelopeCommand`.
|
||||
- Keep existing validation rules unchanged.
|
||||
|
||||
**Output**
|
||||
- Full backend contract parity with the old system.
|
||||
|
||||
---
|
||||
|
||||
## 8) Remove Session Cache Logic
|
||||
|
||||
**Goal:** Fully remove `esid` and memory cache dependency.
|
||||
|
||||
**Tasks**
|
||||
- Remove `SupplyParameterFromQuery(Name = "esid")`.
|
||||
- Remove `IMemoryCache` injection and `PersistSession` flow.
|
||||
- Manage editor state using component state + loaded envelope data only.
|
||||
- Clarify reset behavior after successful save/send.
|
||||
|
||||
**Output**
|
||||
- Session-id-based cache layer is removed completely.
|
||||
|
||||
---
|
||||
|
||||
## 9) Testing and Acceptance Criteria
|
||||
|
||||
**Goal:** Verify migration is complete and safe.
|
||||
|
||||
**Critical manual tests**
|
||||
- PDF upload -> are pages visually accurate?
|
||||
- Continuous/single-page mode transitions work correctly?
|
||||
- Overlay boxes keep correct position across zoom levels?
|
||||
- Add/drag/remove works stably on all pages?
|
||||
- Draft save and send payload format remains identical?
|
||||
- In edit mode (existing envelope), fields load and remain editable?
|
||||
|
||||
**Definition of done**
|
||||
- DevExpress PDF viewer is fully removed from the editor.
|
||||
- Users can edit signature box positions after placing them.
|
||||
- Backend contract and coordinate units remain unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 10) Suggested Implementation Sequence (Short Sprint Plan)
|
||||
|
||||
1. **WASM page copy + route stabilization**
|
||||
2. **PDF raster API + renderer integration**
|
||||
3. **Image viewer (zoom + navigation + modes)**
|
||||
4. **Overlay signature layer (add/drag/remove)**
|
||||
5. **Save/Send payload parity verification**
|
||||
6. **Session/cache removal cleanup**
|
||||
7. **End-to-end testing and UI polish**
|
||||
|
||||
### Implementation Progress Notes
|
||||
|
||||
- Localization requirement clarified: all new Sender Editor UI strings must go through `IStringLocalizer<Resource>` with DE/EN/FR resource entries.
|
||||
Reference in New Issue
Block a user