Compare commits

..

29 Commits

Author SHA1 Message Date
bf739c1360 Improve download/print state handling and toolbar UI
Added `IsDownloading` and `IsPrinting` properties to manage
state during download and print operations in
`SenderPdfImageEditor.razor` and `SenderPdfImageEditorToolbar.razor`.
Updated `OnDownloadPdfAsync` and `OnPrintPdfAsync` to ensure
state cleanup with `finally` blocks.

Enhanced toolbar buttons to reflect busy states with dynamic
titles, disabled states, and a spinning animation. Updated
CSS in `envelope-viewer.css` to improve layout, spacing,
and responsiveness. Added hover, active, and focus-visible
styles for better interactivity. Styled a new zoom slider
with custom thumb and focus states.

Increased maximum width of key elements to 1040px and
adjusted padding/margin for better alignment. Improved
focus styles for input fields and aligned actions to the
right in specific scenarios.
2026-09-30 17:09:33 +02:00
0f167d731e Add PDF printing functionality to editor components
Enabled PDF printing in `SenderPdfImageEditor` and `SenderPdfImageEditorToolbar`. Added `CanPrintPdf` property to determine print availability and a new `OnPrintPdfAsync` method for handling print logic via JavaScript interop. Updated the toolbar with a "Print" button that triggers the print functionality.

Implemented the `printDataUrlPdf` function in `envelope-editor.js` to handle printing from a data URL. This function creates an iframe, loads the PDF, and invokes the browser's print functionality with proper cleanup of resources.
2026-09-30 17:02:05 +02:00
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
e2322b41cd Add zoom functionality to SenderPdfImageEditor
Introduced zoom controls to the `SenderPdfImageEditor` component, including zoom in/out, dynamic zoom levels, and a slider in the toolbar.

- Added zoom-related properties and methods to manage zoom levels.
- Updated the toolbar with zoom buttons and a slider for precise control.
- Enhanced JavaScript to handle zoom overrides and smooth scrolling.
- Modified UI elements to dynamically apply zoom styles.
- Improved accessibility and usability with appropriate attributes.
- Refactored code to integrate zoom functionality seamlessly.

These changes enhance the user experience by providing greater control over PDF viewing and editing.
2026-09-30 16:41:57 +02:00
8386b85592 Add PDF navigation to SenderPdfImageEditor
Enhanced SenderPdfImageEditor with page navigation features:
- Added methods to navigate to first, previous, next, last, or specific pages.
- Introduced `_currentPage` to track and validate the current page.
- Implemented `OnParametersSet` to initialize/clamp `_currentPage`.
- Integrated JavaScript interop for scrolling and visible page detection.

Updated SenderPdfImageEditorToolbar:
- Added navigation buttons with enable/disable states.
- Replaced static page display with input and dropdown for page selection.
- Added event callbacks for navigation actions.

Styled new toolbar elements in `envelope-viewer.css` and added:
- Input field and dropdown for page selection.
- Adjusted layout and appearance of navigation controls.

Extended `envelope-editor.js` with:
- `scrollToPdfPage` for smooth scrolling to a specific page.
- `getVisiblePdfPageNumber` to detect the most visible page.
2026-09-30 16:12:38 +02:00
640f0b95fe Add "Notes" section to document pending toolbar polish
The "Approval Gates" section in `SENDER_PDF_TOOLBAR_PLAN_EN.md`
was updated to include a new "Notes" subheading. A checklist
item was added under this section to indicate that visual
polish for the toolbar (e.g., icon sizing, balance, and
spacing adjustments) is still pending. This change provides
clarity on outstanding design tasks.
2026-09-30 15:59:10 +02:00
181f7f4dec Refine UI spacing and dimensions for compact layout
Reduced gaps, padding, and margins across various elements to create a more compact and visually streamlined layout. Adjusted `min-height`, `border-radius`, and `box-shadow` properties for toolbars and buttons to improve consistency. Updated dimensions and font sizes for value boxes and icons to align with the refined design. Applied similar adjustments for resizing states to maintain uniformity.
2026-09-30 15:58:51 +02:00
95961a296f Refactor SenderPdfImageEditor layout and styles
Refactored `SenderPdfImageEditor.razor` to improve readability and maintainability by introducing a new wrapper element (`sender-image-editor-shell`) and reorganizing the toolbar into a sticky container (`sender-image-editor__toolbar-host`). Updated conditional rendering logic to handle `PageCount` cases more effectively.

Enhanced styles in `envelope-viewer.css`:
- Added `.sender-image-editor-shell` for layout structure.
- Made toolbar sticky with `.sender-image-editor__toolbar-host`.
- Improved alignment, spacing, and responsiveness of elements.
- Adjusted padding and margins for better layout consistency.

These changes improve user experience and maintainability.
2026-09-30 15:52:46 +02:00
db613358d5 Add PDF editor toolbar with navigation and zoom controls
Introduced a `SenderPdfImageEditorToolbar` component to enhance the PDF editor with navigation, zoom, and action controls. Updated `SenderPdfImageEditor.razor` to display the toolbar when `PageCount > 0`.

Added parameters (`CurrentPage`, `TotalPages`, `ZoomPercent`) and computed properties to manage and display toolbar state dynamically.

Styled the toolbar in `envelope-viewer.css` with grid-based layout, responsive design, and hover/disabled states for buttons. Adjusted styles for smaller screens to improve usability.
2026-09-30 15:25:29 +02:00
7a126050cf Add migration plan for SenderPdfImageEditor toolbar
Introduce a detailed migration plan to design and integrate a reusable toolbar component for the `SenderPdfImageEditor` in the `EnvelopeGenerator.Server` project.

The toolbar will support page navigation, zoom in/out, PDF download, and print functionality while preserving existing signature add/remove/drag-drop behavior.

The plan includes:
- Creation of a new `SenderPdfImageEditorToolbar` component.
- Updates to `.razor`, `.css`, and `.js` files.
- A non-regression strategy with incremental feature additions and manual black-box testing after each stage.

The migration is divided into five stages:
1. Visual toolbar skeleton.
2. Page navigation.
3. Zoom in/out with mouse override.
4. Download original PDF bytes.
5. Print output.

Approval gates are defined to ensure functionality is implemented correctly and regressions are avoided.
2026-09-30 15:18:25 +02:00
7081c5f64e Refactor PDF editor styles and add throttling logic
Refactored `SenderPdfImageEditor.razor` to use culture-invariant formatting for dynamic styles and introduced the `GetPageCanvasStyle` method for calculating `aspect-ratio` and `cursor` styles. Enhanced layout control with additional CSS properties.

Updated `envelope-editor.js` to include a throttling mechanism for mouse movement events using `_moveThrottle` and `requestAnimationFrame`. Added `getClickCoordsOnPdfPageThrottled` with a promise-based approach to improve performance during rapid interactions.
2026-09-30 15:16:23 +02:00
12ec5b28fa Update cursor behavior in envelope-viewer.css
Changed the `cursor` property for `.sender-image-editor__field`
from `grab` to `move` and for its `:active` pseudo-class
from `grabbing` to `move`. This ensures consistent cursor
behavior when interacting with the element.
2026-09-30 15:15:48 +02:00
925e60a45c Update routing for sender editor pages
Refactored routing logic in `SenderEditorPage.razor` and `EnvelopeSenderEditorPage.razor`:
- Changed the route in `SenderEditorPage.razor` from `/sender/editor2` to `/sender/editor`.
- Updated `EnvelopeSenderEditorPage.razor` to use `/sender/editor_legacy` instead of `/sender/editor`.
- Adjusted `NavigationManager.NavigateTo` calls to reflect the new routes.

These changes separate the functionality of the two pages and clarify their respective roles.
2026-09-30 15:15:30 +02:00
b0b9ba7212 Revert "Update routes and navigation logic in editor pages"
This reverts commit 33c75ea7e1.
2026-09-30 13:49:46 +02:00
33c75ea7e1 Update routes and navigation logic in editor pages
Updated the route for `SenderEditorPage.razor` to `/sender/editor` with `InteractiveWebAssembly` mode. Changed the route for `EnvelopeSenderEditorPage.razor` to `/sender/editorL` with `InteractiveServer` mode. Adjusted navigation logic in `EnvelopeSenderEditorPage.razor` to reflect the new `/sender/editorL` route for `esid`, `editQuery`, and `envelopeId` parameters.
2026-09-30 10:25:05 +02:00
513efa0833 Improve drag-and-drop in SenderPdfImageEditor
Replaced the old drag-and-drop implementation with a custom solution using mouse events for better control and precision. Added `_isDragging` state and dynamic cursor updates to enhance user feedback during drag operations.

Introduced grab offset calculations to ensure accurate field positioning relative to the pointer. Added `OnEditorMouseUp` to reset the drag state and `GetPageSize` to retrieve page dimensions, defaulting to A4 if unavailable.

Updated `envelope-editor.js` to include `pageNumber` in `getClickCoordsOnPdfPage` and removed the obsolete `getDropCoordsOnPage` function. Enhanced `EditorCoords` to include `PageNumber` for improved page identification.
2026-09-30 09:20:41 +02:00
0c81d79a9b Add drag-and-drop support for PDF field repositioning
Introduced drag-and-drop functionality for fields in the PDF editor:
- Added `SenderPdfFieldMoveRequest` record to encapsulate field movement data.
- Enhanced `SenderPdfImageEditor.razor` with drag-and-drop event handlers (`OnFieldDragStart`, `OnPageDropAsync`) and updated field structure.
- Updated `SenderEditorPage.razor` to handle field movement via the `OnMoveFieldAsync` method.
- Improved CSS styles for draggable fields and added a delete button with hover effects.
- Added `getDropCoordsOnPage` function in `envelope-editor.js` to calculate normalized drop coordinates.

These changes improve the user experience by enabling intuitive field placement and management in the PDF editor.
2026-09-30 09:08:17 +02:00
696cd1b623 Enhance PDF upload UX with progress and state handling
Refactor the PDF upload button to dynamically adjust its
appearance and behavior based on `_isPageReady` and
`_isPdfUploadInProgress` states. Add a spinner to indicate
upload progress and disable the `<InputFile>` component
during uploads or when the page is not ready.

Introduce `_isPdfUploadInProgress` state to prevent multiple
uploads and ensure consistent UI updates. Update the
`OnPdfFileSelectedAsync` method to handle this state,
including a `finally` block to reset it and trigger UI
updates.

Consolidate redundant code for disabled states and improve
conditional rendering for better maintainability. Enhance
visual feedback for loading states and make minor style
adjustments for consistency.
2026-09-29 22:18:25 +02:00
2434f67627 Add conditional rendering for PDF upload readiness
Introduced `_isPageReady` state to manage page readiness and conditionally render the PDF upload button. Disabled the upload functionality and added a loading spinner when the page is not ready. Updated `OnPdfFileSelectedAsync` to prevent uploads before readiness.

Modified the "No Document Loaded" section to use `_isPageReady` for conditional rendering. Added `SENDER_SAVE_EDIT_MIGRATION_PLAN.md` to the solution. Improved user experience with better feedback during loading states.
2026-09-29 21:41:03 +02:00
776700b247 feat(sender-editor): use rendered page metadata for field placement 2026-09-29 19:22:01 +02:00
7a114f47a6 feat(sender-editor): add PDF page rendering pipeline 2026-09-29 19:21:51 +02:00
ed44d841e7 Refactor PDF editor with custom rendering
Replaced `DxPdfViewer` with a custom PDF rendering solution in `SenderPdfImageEditor` to improve flexibility and control. Added support for displaying placeholder messages and interactive signature fields. Introduced the `SenderPdfFieldOverlay` record to encapsulate field data.

Updated `SenderEditorPage` logic to manage page counts, image URLs, and field overlays. Added a method to estimate PDF page counts. Refactored field removal logic to use index-based operations.

Enhanced styling in `envelope-viewer.css` for improved layout and responsiveness. Updated localization files with new strings for page labels and placeholders. Adjusted JavaScript selectors in `envelope-editor.js` to support the new rendering approach.
2026-09-29 16:01:42 +02:00
4bd0cc2e94 Remove DevExpress PDF viewer dependency
Transitioned Sender Editor away from DevExpress PDF viewer, introducing alternative solutions for PDF rasterization. Enforced new localization requirements for UI text using `IStringLocalizer<Resource>` with translations in DE/EN/FR resource files.

Ensured data compatibility for `CreateEnvelopeCommand` payload shape, field units, receiver-field mapping, and save/send behavior. Removed `esid` query parameter and `IMemoryCache`-based session persistence.

Prohibited use of `pdf.js` and DevExpress PDF viewer, requiring an MIT-licensed alternative if DevExpress rasterization is unsuitable. Maintained adherence to Clean Architecture layers and MediatR pattern. All code and comments are now standardized in English.
2026-09-29 15:17:56 +02:00
8a4963472c Add PDF editing and envelope management features
Introduced a new `SenderEditorPage` with a modern UI for managing envelopes, uploading PDFs, and placing signature fields. Integrated `DevExpress.Blazor.PdfViewer` for PDF rendering and interaction.

Key features include:
- PDF upload, rendering, and validation.
- Signature field placement with normalized coordinates.
- Receiver management with validation and email suggestions.
- Envelope metadata handling (title, message).
- Save as draft and send functionality with validation.
- Edit mode for existing envelopes.
- Improved UX with dynamic elements and color-coded receivers.
- Localization support for multi-language UI.

Updated `_Imports.razor` to include necessary namespaces for DevExpress components and the new `PdfEditor` namespace.
2026-09-29 11:59:06 +02:00
4d3f72a3ed Add ReceiverService and enhance DocumentService
Added `ReceiverService` to the dependency injection container in `Program.cs` and implemented its functionality for fetching receiver email suggestions via API.

Enhanced `DocumentService` with a new method `GetSenderDocumentDetailsByEnvelopeIdAsync` to fetch sender document details by envelope ID. Introduced JSON handling improvements with `JsonSerializerOptions`.

These changes improve support for receiver-related operations and extend document-related capabilities.
2026-09-29 11:58:29 +02:00
1ebd43e3b0 Remove envelope-related PDF viewer components
The following files have been removed as part of a significant refactor or deprecation of envelope-related PDF viewing and signature functionality:

- `EnvelopeReceiverPage.razor`: A detailed PDF viewer with signature handling and interactive features.
- `EnvelopeReceiverPage_DxPdfViewer.razor`: A simplified PDF viewer using the `DxPdfViewer` component.
- `EnvelopeReceiverPage_DxReportViewer.razor`: A report viewer using the `DxReportViewer` component.
- `EnvelopeReceiverPage_embed.razor`: A minimalistic PDF viewer using the `<embed>` HTML tag.

These removals indicate a transition to a new approach for handling PDF viewing and related features or their complete deprecation.
2026-09-29 09:55:13 +02:00
19aeda5aef Add migration plan for WASM-based PDF editor component
A detailed migration plan has been added to replace the DevExpress PDF viewer in `EnvelopeSenderEditorPage.razor` with a new WebAssembly (WASM)-based PDF editor component.

The plan outlines the scope, constraints, and implementation strategy, ensuring feature parity, backend compatibility, and minimal disruption to the existing user experience. Key decisions include creating a reusable `SenderPdfImageEditor` component, using server-side PDF rasterization, and removing `esid` and `IMemoryCache`-based session persistence.

The document also defines testing and acceptance criteria to verify the migration's success, focusing on visual fidelity, functionality, and backend contract consistency.
2026-09-29 09:41:09 +02:00
940d129c11 Add read-only sharing and viewing functionality
Introduced the ability to share envelopes in read-only mode and view them via a dedicated page. Added `ShareEnvelopeReadOnlyAsync` in `AuthService.cs` to handle sharing logic. Updated `ReceiverPage.razor` with a "Share" button, popup UI, and validation for sharing inputs. Created `ReceiverReadOnlyPage.razor` for read-only document viewing with error handling and lifecycle management.

Added `EnvelopeKeyRedirectController.cs` to support legacy link redirection. Enhanced `ReadOnlyController.cs` to support envelope sharing with improved logging and `IMediator` integration. Improved state management, validation, and error handling across components. Performed general code cleanup and UI enhancements for better user experience.
2026-09-28 14:42:52 +02:00
44bbdbb835 Add logout functionality and signed status check
Introduced a logout feature to the `ReceiverPage` component:
- Added a logout button to the toolbar with `_isLoggingOut` state.
- Implemented `LogoutAsync` method to handle logout logic, including navigation and error handling.
- Injected `IMediator` and `ILogger<ReceiverPage>` for logging and MediatR functionality.
- Added a check in `OnInitializedAsync` to redirect users to `/envelope/signed-result` if the envelope is already signed.
- Updated namespaces to include MediatR and custom extensions.

These changes improve user experience and enhance the component's functionality.
2026-09-28 14:11:06 +02:00
42 changed files with 3594 additions and 1925 deletions

View File

@@ -236,12 +236,6 @@ Manual testing workflow:
3. Test sender login at `/sender/login`
4. Test receiver flow at `/envelope/login/{envelopeKey}`
## Technical Debt TODOs
- Refactor `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor` to reduce complexity.
- Move sender dashboard business/status logic into dedicated services and extension methods (for example effective status resolution, tab classification, receiver signed/rejected detection, and grid-layout filter sanitation).
- Keep `EnvelopeSenderPage.razor` focused on UI composition/state orchestration; avoid embedding heavy domain logic directly in the Razor component.
## Database
**SQL Server** (DD_ECM)

View 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;
}

View File

@@ -0,0 +1,6 @@
namespace EnvelopeGenerator.Application.Common.Dto;
public sealed class PdfRenderRequestDto
{
public byte[]? PdfBytes { get; init; }
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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);
}
}

View File

@@ -0,0 +1,7 @@
namespace EnvelopeGenerator.Server.Client.Components.PdfEditor;
public sealed record SenderPdfFieldMoveRequest(
int Index,
int Page,
double XPt,
double YPt);

View File

@@ -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);

View File

@@ -0,0 +1,459 @@
@using EnvelopeGenerator.Application.Resources
@using EnvelopeGenerator.Application.Common.Dto
@using System.Globalization
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.Extensions.Localization
@inject IStringLocalizer<Resource> UiLocalizer
@inject IJSRuntime JSRuntime
<div class="sender-image-editor-shell">
@if (PageCount > 0)
{
<div class="sender-image-editor__toolbar-host">
<SenderPdfImageEditorToolbar CurrentPage="@_currentPage"
TotalPages="@PageCount"
ZoomPercent="@_zoomPercent"
MinZoomPercent="@MinZoomPercent"
MaxZoomPercent="@MaxZoomPercent"
ZoomStepPercent="@ZoomStepPercent"
GoFirstPage="OnGoFirstPageAsync"
GoPreviousPage="OnGoPreviousPageAsync"
GoNextPage="OnGoNextPageAsync"
GoLastPage="OnGoLastPageAsync"
GoToPage="OnGoToPageAsync"
ZoomOut="OnZoomOutAsync"
ZoomIn="OnZoomInAsync"
SetZoomPercent="OnSetZoomPercentAsync"
CanDownloadPdf="@CanDownloadPdf"
CanPrintPdf="@CanPrintPdf"
IsDownloading="@_isDownloading"
IsPrinting="@_isPrinting"
DownloadPdf="OnDownloadPdfAsync"
PrintPdf="OnPrintPdfAsync" />
</div>
}
<div class="pdf-editor-wrapper sender-image-editor sender-editor-pdf-viewer"
style="cursor: @(IsPlacementMode ? "crosshair" : (_isDragging ? "grabbing" : "default"));"
tabindex="0"
@onclick="HandleClickAsync"
@onwheel="OnEditorWheelAsync"
@onwheel:preventDefault="true"
@onscroll="OnEditorScrollAsync"
@onmousemove="OnEditorMouseMoveAsync"
@onmouseup="OnEditorMouseUp"
@onmouseleave="OnEditorMouseUp">
@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" style="@GetZoomStyle()">
<iframe class="sender-image-editor__native-frame"
src="@PdfDataUrl"
title="PDF Preview"></iframe>
</div>
}
<div class="sender-image-editor__pages" style="@GetZoomStyle()">
@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"
style="@GetPageCanvasStyle(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"
@onmousedown="args => OnFieldMouseDownAsync(args, field)"
@onmousedown:preventDefault="true"
@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>
</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 string? PdfFileName { 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 bool _isDragging;
private double _grabOffsetXPt;
private double _grabOffsetYPt;
private int _currentPage = 1;
private int _zoomPercent = 112;
private bool _zoomOverrideInitialized;
private bool _isDownloading;
private bool _isPrinting;
private const int MinZoomPercent = 50;
private const int MaxZoomPercent = 300;
private const int ZoomStepPercent = 5;
private bool CanDownloadPdf => !string.IsNullOrWhiteSpace(PdfDataUrl);
private bool CanPrintPdf => !string.IsNullOrWhiteSpace(PdfDataUrl);
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;
var left = leftPercent.ToString("F4", CultureInfo.InvariantCulture);
var top = topPercent.ToString("F4", CultureInfo.InvariantCulture);
var width = widthPercent.ToString("F4", CultureInfo.InvariantCulture);
var height = heightPercent.ToString("F4", CultureInfo.InvariantCulture);
return string.Join(' ',
"position:absolute;",
$"left:{left}%;",
$"top:{top}%;",
$"width:{width}%;",
$"height:{height}%;",
"border:2px solid;",
"border-radius:8px;",
"display:flex;",
"align-items:flex-start;",
"justify-content:space-between;",
"gap:0.35rem;",
"padding:0.2rem 0.3rem;",
"overflow:hidden;",
"z-index:30;",
$"border-color:{field.Color};",
$"background-color:{field.Color}22;");
}
private string GetPageCanvasStyle(double pageWidthPt, double pageHeightPt)
{
var ratio = pageHeightPt <= 0 ? 595.0 / 842.0 : pageWidthPt / pageHeightPt;
var ratioText = ratio.ToString("F6", CultureInfo.InvariantCulture);
var cursor = _isDragging
? "grabbing"
: (IsPlacementMode ? "crosshair" : "default");
return $"aspect-ratio:{ratioText};cursor:{cursor};";
}
private string GetZoomStyle()
{
var factor = (_zoomPercent / 100.0).ToString("F4", CultureInfo.InvariantCulture);
return $"zoom:{factor};";
}
protected override void OnParametersSet()
{
if (PageCount <= 0)
{
_currentPage = 1;
return;
}
_currentPage = Math.Max(1, Math.Min(_currentPage, PageCount));
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_zoomOverrideInitialized)
return;
await JSRuntime.InvokeVoidAsync(
"envelopeEditor.ensureZoomOverride",
"sender-editor-pdf-viewer");
_zoomOverrideInitialized = true;
}
private async Task HandleClickAsync(MouseEventArgs args)
{
if (EditorClick.HasDelegate)
await EditorClick.InvokeAsync(args);
}
private async Task OnFieldMouseDownAsync(MouseEventArgs args, SenderPdfFieldOverlay field)
{
var coords = await JSRuntime.InvokeAsync<EditorCoords?>(
"envelopeEditor.getClickCoordsOnPdfPage",
"sender-editor-pdf-viewer",
args.ClientX,
args.ClientY);
if (coords is null)
return;
var pageNumber = coords.PageNumber;
var (pageWidthPt, pageHeightPt) = GetPageSize(pageNumber);
var pointerXPt = coords.NormX * pageWidthPt;
var pointerYPt = coords.NormY * pageHeightPt;
_draggedFieldIndex = field.Index;
_isDragging = true;
_grabOffsetXPt = pointerXPt - field.XPt;
_grabOffsetYPt = pointerYPt - field.YPt;
}
private async Task OnEditorMouseMoveAsync(MouseEventArgs args)
{
if (_draggedFieldIndex is null || !MoveField.HasDelegate)
return;
var coords = await JSRuntime.InvokeAsync<EditorCoords?>(
"envelopeEditor.getClickCoordsOnPdfPage",
"sender-editor-pdf-viewer",
args.ClientX,
args.ClientY);
if (coords is null)
return;
var pageNumber = coords.PageNumber;
var (pageWidthPt, pageHeightPt) = GetPageSize(pageNumber);
const double sigWidthPt = 1.77 * 72;
const double sigHeightPt = 1.96 * 72;
var xPt = (coords.NormX * pageWidthPt) - _grabOffsetXPt;
var yPt = (coords.NormY * pageHeightPt) - _grabOffsetYPt;
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));
}
private async Task OnGoFirstPageAsync()
{
await NavigateToPageAsync(1);
}
private async Task OnGoPreviousPageAsync()
{
await NavigateToPageAsync(_currentPage - 1);
}
private async Task OnGoNextPageAsync()
{
await NavigateToPageAsync(_currentPage + 1);
}
private async Task OnGoLastPageAsync()
{
await NavigateToPageAsync(PageCount);
}
private async Task OnGoToPageAsync(int page)
{
await NavigateToPageAsync(page);
}
private async Task NavigateToPageAsync(int targetPage)
{
if (PageCount <= 0)
return;
var page = Math.Max(1, Math.Min(targetPage, PageCount));
_currentPage = page;
await JSRuntime.InvokeVoidAsync(
"envelopeEditor.scrollToPdfPage",
"sender-editor-pdf-viewer",
page);
}
private async Task OnEditorScrollAsync(EventArgs _)
{
if (PageCount <= 0)
return;
var visiblePage = await JSRuntime.InvokeAsync<int?>(
"envelopeEditor.getVisiblePdfPageNumber",
"sender-editor-pdf-viewer");
if (!visiblePage.HasValue)
return;
var clamped = Math.Max(1, Math.Min(visiblePage.Value, PageCount));
if (clamped == _currentPage)
return;
_currentPage = clamped;
await InvokeAsync(StateHasChanged);
}
private async Task OnZoomOutAsync()
{
SetZoomPercent(_zoomPercent - ZoomStepPercent);
await InvokeAsync(StateHasChanged);
}
private async Task OnZoomInAsync()
{
SetZoomPercent(_zoomPercent + ZoomStepPercent);
await InvokeAsync(StateHasChanged);
}
private async Task OnSetZoomPercentAsync(int zoomPercent)
{
SetZoomPercent(zoomPercent);
await InvokeAsync(StateHasChanged);
}
private async Task OnEditorWheelAsync(WheelEventArgs args)
{
if (args.CtrlKey)
{
if (args.DeltaY < 0)
await OnZoomInAsync();
else if (args.DeltaY > 0)
await OnZoomOutAsync();
return;
}
await JSRuntime.InvokeVoidAsync(
"envelopeEditor.scrollPdfViewerByDelta",
"sender-editor-pdf-viewer",
args.DeltaY);
}
private void SetZoomPercent(int value)
{
_zoomPercent = Math.Max(MinZoomPercent, Math.Min(value, MaxZoomPercent));
}
private async Task OnDownloadPdfAsync()
{
if (string.IsNullOrWhiteSpace(PdfDataUrl) || _isDownloading || _isPrinting)
return;
_isDownloading = true;
await InvokeAsync(StateHasChanged);
try
{
var fallbackName = string.IsNullOrWhiteSpace(PdfFileName)
? "document.pdf"
: PdfFileName;
var fileName = fallbackName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)
? fallbackName
: $"{fallbackName}.pdf";
await JSRuntime.InvokeVoidAsync(
"envelopeEditor.downloadDataUrlAsFile",
PdfDataUrl,
fileName,
"application/pdf");
}
finally
{
_isDownloading = false;
await InvokeAsync(StateHasChanged);
}
}
private async Task OnPrintPdfAsync()
{
if (string.IsNullOrWhiteSpace(PdfDataUrl) || _isPrinting || _isDownloading)
return;
_isPrinting = true;
await InvokeAsync(StateHasChanged);
try
{
await JSRuntime.InvokeVoidAsync(
"envelopeEditor.printDataUrlPdf",
PdfDataUrl,
"application/pdf");
}
finally
{
_isPrinting = false;
await InvokeAsync(StateHasChanged);
}
}
private void OnEditorMouseUp(MouseEventArgs args)
{
_draggedFieldIndex = null;
_isDragging = false;
}
private (double WidthPt, double HeightPt) GetPageSize(int pageNumber)
{
var page = Pages.FirstOrDefault(p => p.PageNumber == pageNumber);
return page is null ? (595.0, 842.0) : (page.WidthPt, page.HeightPt);
}
private sealed record EditorCoords(double NormX, double NormY, int PageIndex, int PageNumber);
private string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
}
}

View File

@@ -0,0 +1,267 @@
<div class="sender-image-toolbar" @onclick:stopPropagation="true">
<div class="sender-image-toolbar__group sender-image-toolbar__group--nav" role="group" aria-label="Page navigation">
<button type="button" class="sender-image-toolbar__icon-btn" title="First page" disabled="@(!CanGoPrevious)" @onclick="GoToFirstAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M8 6V18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M16 7L11 12L16 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button" class="sender-image-toolbar__icon-btn" title="Previous page" disabled="@(!CanGoPrevious)" @onclick="GoToPreviousAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M15 7L10 12L15 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div class="sender-image-toolbar__value-box">
<input type="number"
min="1"
max="@TotalPagesDisplay"
value="@CurrentPageDisplay"
class="sender-image-toolbar__page-input"
@onchange="OnPageInputChangedAsync"
disabled="@(TotalPagesDisplay <= 0)" />
<span class="sender-image-toolbar__page-total">of @TotalPagesDisplay</span>
</div>
<div class="sender-image-toolbar__select-wrap" title="Page list">
<button type="button" class="sender-image-toolbar__icon-btn sender-image-toolbar__icon-btn--caret" disabled="@(TotalPagesDisplay <= 0)">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<select class="sender-image-toolbar__page-select"
aria-label="Select page"
disabled="@(TotalPagesDisplay <= 0)"
value="@CurrentPageDisplay"
@onchange="OnPageSelectChangedAsync">
@for (var pageNumber = 1; pageNumber <= TotalPagesDisplay; pageNumber++)
{
<option value="@pageNumber">@pageNumber</option>
}
</select>
</div>
<button type="button" class="sender-image-toolbar__icon-btn" title="Next page" disabled="@(!CanGoNext)" @onclick="GoToNextAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M9 7L14 12L9 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button" class="sender-image-toolbar__icon-btn" title="Last page" disabled="@(!CanGoNext)" @onclick="GoToLastAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M16 6V18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M8 7L13 12L8 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
<div class="sender-image-toolbar__group sender-image-toolbar__group--zoom" role="group" aria-label="Zoom controls">
<button type="button" class="sender-image-toolbar__icon-btn" title="Zoom out" disabled="@(!CanZoomOut)" @onclick="ZoomOutAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 12H18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div class="sender-image-toolbar__value-box" aria-hidden="true">
<span>@ZoomPercentDisplay%</span>
</div>
<input type="range"
class="sender-image-toolbar__zoom-slider"
min="@MinZoomPercent"
max="@MaxZoomPercent"
step="@ZoomStepPercent"
value="@ZoomPercentDisplay"
@oninput="OnZoomSliderInputAsync"
aria-label="Zoom slider" />
<button type="button" class="sender-image-toolbar__icon-btn" title="Zoom in" disabled="@(!CanZoomIn)" @onclick="ZoomInAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 6V18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M6 12H18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
<div class="sender-image-toolbar__group sender-image-toolbar__group--actions" role="group" aria-label="Document actions">
<button type="button"
class="sender-image-toolbar__icon-btn @(IsPrinting ? "sender-image-toolbar__icon-btn--busy" : string.Empty)"
title="@(IsPrinting ? "Preparing print..." : "Print")"
disabled="@(!CanPrintPdf || IsPrinting || IsDownloading)"
@onclick="PrintPdfAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M7 9V4H17V9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<rect x="5" y="9" width="14" height="8" rx="2" stroke="currentColor" stroke-width="2" />
<path d="M7 14H17V20H7V14Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
</svg>
</button>
<button type="button"
class="sender-image-toolbar__icon-btn @(IsDownloading ? "sender-image-toolbar__icon-btn--busy" : string.Empty)"
title="@(IsDownloading ? "Preparing download..." : "Download PDF")"
disabled="@(!CanDownloadPdf || IsDownloading || IsPrinting)"
@onclick="DownloadPdfAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 4V14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M8 10L12 14L16 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M5 18H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
</div>
@code {
[Parameter]
public int CurrentPage { get; set; }
[Parameter]
public int TotalPages { get; set; }
[Parameter]
public int ZoomPercent { get; set; } = 112;
[Parameter]
public int MinZoomPercent { get; set; } = 50;
[Parameter]
public int MaxZoomPercent { get; set; } = 300;
[Parameter]
public int ZoomStepPercent { get; set; } = 5;
[Parameter]
public EventCallback GoFirstPage { get; set; }
[Parameter]
public EventCallback GoPreviousPage { get; set; }
[Parameter]
public EventCallback GoNextPage { get; set; }
[Parameter]
public EventCallback GoLastPage { get; set; }
[Parameter]
public EventCallback<int> GoToPage { get; set; }
[Parameter]
public EventCallback ZoomOut { get; set; }
[Parameter]
public EventCallback ZoomIn { get; set; }
[Parameter]
public EventCallback<int> SetZoomPercent { get; set; }
[Parameter]
public bool CanDownloadPdf { get; set; }
[Parameter]
public bool CanPrintPdf { get; set; }
[Parameter]
public bool IsDownloading { get; set; }
[Parameter]
public bool IsPrinting { get; set; }
[Parameter]
public EventCallback DownloadPdf { get; set; }
[Parameter]
public EventCallback PrintPdf { get; set; }
private int CurrentPageDisplay => CurrentPage > 0 ? CurrentPage : 0;
private int TotalPagesDisplay => TotalPages > 0 ? TotalPages : 0;
private int ZoomPercentDisplay => ZoomPercent > 0 ? ZoomPercent : 100;
private bool CanGoPrevious => CurrentPageDisplay > 1;
private bool CanGoNext => TotalPagesDisplay > 0 && CurrentPageDisplay < TotalPagesDisplay;
private bool CanZoomOut => ZoomPercentDisplay > MinZoomPercent;
private bool CanZoomIn => ZoomPercentDisplay < MaxZoomPercent;
private async Task GoToFirstAsync()
{
if (GoFirstPage.HasDelegate)
await GoFirstPage.InvokeAsync();
}
private async Task GoToPreviousAsync()
{
if (GoPreviousPage.HasDelegate)
await GoPreviousPage.InvokeAsync();
}
private async Task GoToNextAsync()
{
if (GoNextPage.HasDelegate)
await GoNextPage.InvokeAsync();
}
private async Task GoToLastAsync()
{
if (GoLastPage.HasDelegate)
await GoLastPage.InvokeAsync();
}
private async Task OnPageInputChangedAsync(ChangeEventArgs args)
{
if (!GoToPage.HasDelegate)
return;
if (!int.TryParse(args.Value?.ToString(), out var page))
return;
page = Math.Max(1, Math.Min(page, Math.Max(1, TotalPagesDisplay)));
await GoToPage.InvokeAsync(page);
}
private async Task OnPageSelectChangedAsync(ChangeEventArgs args)
{
if (!GoToPage.HasDelegate)
return;
if (!int.TryParse(args.Value?.ToString(), out var page))
return;
page = Math.Max(1, Math.Min(page, Math.Max(1, TotalPagesDisplay)));
await GoToPage.InvokeAsync(page);
}
private async Task ZoomOutAsync()
{
if (ZoomOut.HasDelegate)
await ZoomOut.InvokeAsync();
}
private async Task ZoomInAsync()
{
if (ZoomIn.HasDelegate)
await ZoomIn.InvokeAsync();
}
private async Task OnZoomSliderInputAsync(ChangeEventArgs args)
{
if (!SetZoomPercent.HasDelegate)
return;
if (!int.TryParse(args.Value?.ToString(), out var zoomPercent))
return;
zoomPercent = Math.Max(MinZoomPercent, Math.Min(zoomPercent, MaxZoomPercent));
await SetZoomPercent.InvokeAsync(zoomPercent);
}
private async Task DownloadPdfAsync()
{
if (DownloadPdf.HasDelegate)
await DownloadPdf.InvokeAsync();
}
private async Task PrintPdfAsync()
{
if (PrintPdf.HasDelegate)
await PrintPdf.InvokeAsync();
}
}

View File

@@ -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>();

View File

@@ -26,24 +26,4 @@ public class AnnotationService(IHttpClientFactory httpClientFactory)
var result = await response.Content.ReadFromJsonAsync<List<AnnotationDto>>(_jsonOptions, cancel);
return result ?? [];
}
public async Task RejectAsync(string envelopeKey, string? reason, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await http.PostAsJsonAsync(
$"/api/Annotation/reject/{Uri.EscapeDataString(envelopeKey)}",
reason,
_jsonOptions,
cancel);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancel);
throw new HttpRequestException(
$"Failed to reject envelope {envelopeKey}: {(int)response.StatusCode} {response.StatusCode} - {body}",
null,
response.StatusCode);
}
}
}

View File

@@ -86,6 +86,18 @@ public class AuthService(IHttpClientFactory httpClientFactory)
return response.IsSuccessStatusCode;
}
/// <summary>
/// Shares the current envelope in read-only mode for the specified receiver and expiration date.
/// Calls POST /api/readonly/{envelopeKey}.
/// </summary>
public async Task<bool> ShareEnvelopeReadOnlyAsync(string envelopeKey, string receiverMail, DateTime dateValid, CancellationToken cancel = default)
{
using var http = CreateDefaultClient();
var payload = new { receiverMail, dateValid };
var response = await http.PostAsJsonAsync($"/api/readonly/{Uri.EscapeDataString(envelopeKey)}", payload, cancel);
return response.IsSuccessStatusCode;
}
/// <summary>
/// Removes the per-envelope receiver cookie for the given envelope key.
/// Calls POST /api/auth/logout/envelope/{envelopeKey}.

View File

@@ -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);
}
}

View File

@@ -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) ?? [];
}
}

View File

@@ -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 ?? [];
}
}

View File

@@ -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

View File

@@ -1,4 +1,4 @@
@page "/sender/editor"
@page "/sender/editor_legacy"
@rendermode InteractiveServer
@using DevExpress.Blazor.PdfViewer
@using DevExpress.Blazor.Reporting.Models
@@ -543,7 +543,7 @@
var editQuery = EnvelopeId.HasValue && EnvelopeId.Value > 0
? $"&envelopeId={EnvelopeId.Value}"
: string.Empty;
NavigationManager.NavigateTo($"/sender/editor?esid={sid}{editQuery}", forceLoad: false);
NavigationManager.NavigateTo($"/sender/editor_legacy?esid={sid}{editQuery}", forceLoad: false);
}
}
@@ -837,7 +837,7 @@
if (previousEnvelopeId != result.Id)
{
var sid = string.IsNullOrWhiteSpace(Esid) ? Guid.NewGuid().ToString("N") : Esid;
NavigationManager.NavigateTo($"/sender/editor?esid={sid}&envelopeId={result.Id}", forceLoad: false);
NavigationManager.NavigateTo($"/sender/editor_legacy?esid={sid}&envelopeId={result.Id}", forceLoad: false);
}
NavigationManager.NavigateTo("/sender", forceLoad: false);

View File

@@ -262,7 +262,7 @@
var envelope = cellContext.DataItem as EnvelopeDto;
if (envelope != null)
{
var statusInfo = GetStatusInfo(GetEffectiveEnvelopeStatus(envelope));
var statusInfo = GetStatusInfo(envelope.Status);
<div class="status-badge status-badge--@statusInfo.CssClass">
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
@statusInfo.Label
@@ -314,20 +314,12 @@
{
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
@{
var isRejected = IsReceiverRejected(envelope, receiver);
var isSigned = IsReceiverSigned(envelope, receiver);
var accessCode = receiver.AccessCode;
var hasAccessCode = !string.IsNullOrWhiteSpace(accessCode);
}
<span class="receiver-badge receiver-badge--@(isRejected ? "unsigned" : (isSigned ? "signed" : "unsigned"))" style="min-width: 100px;">
@if (isRejected)
{
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
</svg>
<span>@GetStatusLabel(EnvelopeStatus.DocumentRejected)</span>
}
else if (isSigned)
<span class="receiver-badge receiver-badge--@(isSigned ? "signed" : "unsigned")" style="min-width: 100px;">
@if (isSigned)
{
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" 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" />
@@ -598,11 +590,10 @@
{
_allEnvelopes = await EnvelopeService.GetAsync() ?? [];
// Split into active and completed based on effective status
// (history-aware: rejected envelopes should not stay in active tab).
// Split into active and completed based on status
var envelopes = _allEnvelopes.ToList();
_activeEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsActive()).ToList();
_completedEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsCompleted()).ToList();
_activeEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsActive()).ToList();
_completedEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsCompleted()).ToList();
}
catch (Exception ex)
{
@@ -754,13 +745,13 @@
bool IsEnvelopeSent(EnvelopeDto envelope)
{
var status = GetEffectiveEnvelopeStatus(envelope);
var status = (EnvelopeStatus)envelope.Status;
return status >= EnvelopeStatus.EnvelopeQueued;
}
bool IsEnvelopeEditable(EnvelopeDto envelope)
{
var status = GetEffectiveEnvelopeStatus(envelope);
var status = (EnvelopeStatus)envelope.Status;
return status is EnvelopeStatus.EnvelopeCreated or EnvelopeStatus.EnvelopeSaved;
}
@@ -809,7 +800,7 @@
IReadOnlyList<StatusFilterItem> GetStatusFilterItems(IEnumerable<EnvelopeDto>? source)
{
return (source ?? Enumerable.Empty<EnvelopeDto>())
.Select(GetEffectiveEnvelopeStatus)
.Select(x => x.Status)
.Distinct()
.OrderBy(x => x)
.Select(x => new StatusFilterItem(x, GetStatusLabel(x)))
@@ -827,7 +818,7 @@
bool IsReceiverSigned(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
{
var status = GetEffectiveEnvelopeStatus(envelope);
var status = (EnvelopeStatus)envelope.Status;
if (status is EnvelopeStatus.EnvelopeCompletelySigned or EnvelopeStatus.MessageCompletionSent)
return true;
@@ -850,43 +841,6 @@
return false;
}
bool IsReceiverRejected(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
{
var status = GetEffectiveEnvelopeStatus(envelope);
var histories = envelope.Histories?.Where(h =>
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected).ToList() ?? [];
if (histories.Count == 0)
return false;
if (histories.Any(h => h.Receiver?.Id == receiver.ReceiverId))
return true;
var receiverEmail = receiver.Receiver?.EmailAddress;
if (!string.IsNullOrWhiteSpace(receiverEmail) &&
histories.Any(h => string.Equals(h.UserReference, receiverEmail, StringComparison.OrdinalIgnoreCase)))
return true;
if (!string.IsNullOrWhiteSpace(receiver.Name) &&
histories.Any(h => string.Equals(h.UserReference, receiver.Name, StringComparison.OrdinalIgnoreCase)))
return true;
return status == EnvelopeStatus.EnvelopeRejected && (envelope.EnvelopeReceivers?.Count() ?? 0) == 1;
}
EnvelopeStatus GetEffectiveEnvelopeStatus(EnvelopeDto envelope)
{
var rawStatus = envelope.Status;
if (rawStatus is EnvelopeStatus.EnvelopeRejected or EnvelopeStatus.EnvelopeWithdrawn)
return rawStatus;
var hasRejectedHistory = (envelope.Histories ?? Enumerable.Empty<HistoryDto>())
.Any(h => h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected);
return hasRejectedHistory ? EnvelopeStatus.EnvelopeRejected : rawStatus;
}
DateTime GetHistoryDate(HistoryDto history)
{
return history.ActionDate ?? history.AddedWhen;
@@ -1029,18 +983,13 @@
// If cache is empty (LoadFromServerAsync failed), skip layout restore silently.
var prefs = UserPreferencesService.GetCached();
if (prefs is not null && prefs.GridLayouts.TryGetValue(CurrentGridKey, out var json))
{
var layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
SanitizeLayoutFilters(layout);
e.Layout = layout;
}
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
}
async Task OnGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
{
try
{
SanitizeLayoutFilters(e.Layout);
var json = JsonSerializer.Serialize(e.Layout);
await UserPreferencesService.SaveGridLayoutAsync(CurrentGridKey, json);
}
@@ -1050,55 +999,5 @@
}
}
// Keep column/order personalization, but never persist/restore data filters.
// Tab counters are based on tab datasets; persisted grid filters can hide rows and
// make the grid appear empty or incomplete.
static void SanitizeLayoutFilters(GridPersistentLayout? layout)
{
if (layout is null)
return;
ClearWritableProperty(layout, "FilterCriteria");
ClearWritableProperty(layout, "SearchText");
if (layout.Columns is not System.Collections.IEnumerable columns)
return;
foreach (var column in columns)
{
if (column is null)
continue;
ClearWritableProperty(column, "FilterCriteria");
ClearWritableProperty(column, "FilterValues");
ClearWritableProperty(column, "FilterValue");
ClearWritableProperty(column, "SearchText");
}
}
static void ClearWritableProperty(object target, string propertyName)
{
var prop = target.GetType()
.GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public)
.FirstOrDefault(p => p.Name == propertyName && p.GetIndexParameters().Length == 0);
if (prop is null || !prop.CanWrite)
return;
var propertyType = prop.PropertyType;
object? value;
if (!propertyType.IsValueType || Nullable.GetUnderlyingType(propertyType) is not null)
{
value = null;
}
else
{
value = Activator.CreateInstance(propertyType);
}
prop.SetValue(target, value);
}
private sealed record StatusFilterItem(EnvelopeStatus Value, string Text);
}

View File

@@ -5,22 +5,27 @@
@using EnvelopeGenerator.Server.Client.Models
@using EnvelopeGenerator.Server.Client.Models.Constants
@using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Application.Common.Extensions
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver
@using EnvelopeGenerator.Application.EnvelopeReceivers.Queries
@using EnvelopeGenerator.Domain.Constants
@using Microsoft.JSInterop
@using DevExpress.Blazor
@using System.Drawing
@using System.Security.Claims
@using System.ComponentModel.DataAnnotations
@using Microsoft.Extensions.Caching.Memory
@using MediatR
@using EnvelopeGenerator.Server.Extensions
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
@inject AnnotationService AnnotationService
@inject AppVersionService AppVersion
@inject IMemoryCache MemoryCache
@inject ILogger<ReceiverPage> Logger
@inject IMediator Mediator
@implements IDisposable
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
@@ -128,15 +133,30 @@
</button>
}
<button class="pdf-toolbar__btn pdf-toolbar__btn--reject"
@onclick="OpenRejectPopup"
disabled="@_isRejectSubmitting"
title="Ablehnen"
@if (!(_envelopeReceiver?.Envelope?.ReadOnly ?? false))
{
<button class="pdf-toolbar__btn"
@onclick="OpenSharePopup"
title="Dokument teilen"
style="flex-shrink: 0;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M13.5 1a1.5 1.5 0 1 0 1.415 2H15a1.5 1.5 0 0 0-1.5-2zm-11 5a1.5 1.5 0 1 0 1.415 2H4a1.5 1.5 0 0 0-1.5-2zm11 5a1.5 1.5 0 1 0 1.415 2H15a1.5 1.5 0 0 0-1.5-2z"/>
<path d="M2.854 7.646a.5.5 0 0 1 .0-.707l9-5a.5.5 0 1 1 .486.874l-9 5a.5.5 0 0 1-.486-.167zm0 .708a.5.5 0 0 1 .486-.167l9 5a.5.5 0 0 1-.486.874l-9-5a.5.5 0 0 1 0-.707z"/>
</svg>
<span class="pdf-toolbar__btn-text">Teilen</span>
</button>
}
<button class="pdf-toolbar__btn"
@onclick="LogoutAsync"
disabled="@_isLoggingOut"
title="Abmelden"
style="flex-shrink: 0;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
<path fill-rule="evenodd" d="M10 15a1 1 0 0 0 1-1v-2a.5.5 0 0 1 1 0v2a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v2a.5.5 0 0 1-1 0V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h7z" />
<path fill-rule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 1 0-.708.708L14.293 7.5H6.5a.5.5 0 0 0 0 1h7.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z" />
</svg>
<span class="pdf-toolbar__btn-text">Ablehnen</span>
<span class="pdf-toolbar__btn-text">@(_isLoggingOut ? "Abmelden..." : "Abmelden")</span>
</button>
</div>
</div>
@@ -355,70 +375,52 @@
</FooterContentTemplate>
</DxPopup>
@* Rejection Popup *@
<DxPopup @bind-Visible="_rejectPopupVisible"
HeaderText="Dokument ablehnen"
Width="500px"
<DxPopup @bind-Visible="_sharePopupVisible"
HeaderText="Dokument teilen"
Width="520px"
MaxWidth="95vw"
ShowFooter="true"
CloseOnOutsideClick="false"
ShowCloseButton="false"
CloseOnEscape="false">
CloseOnOutsideClick="true">
<BodyContentTemplate>
<div style="display: flex; align-items: flex-start; gap: 0.9rem; padding: 0.2rem 0 0.8rem;">
<div style="flex-shrink: 0; width: 36px; height: 36px; border-radius: 999px; background: #fee2e2; color: #b91c1c; display: inline-flex; align-items: center; justify-content: center;">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
</svg>
</div>
<div style="min-width: 0;">
<div style="font-size: 0.95rem; font-weight: 600; color: #1f2937; margin-bottom: 0.3rem;">
Möchten Sie dieses Dokument wirklich ablehnen?
</div>
<div style="font-size: 0.84rem; color: #6b7280; line-height: 1.45;">
Diese Aktion markiert den Umschlag als abgelehnt und informiert den Absender.
</div>
</div>
<div class="mb-3">
<label class="form-label" for="rp-share-mail" style="font-size: 0.9rem; font-weight: 500;">E-Mail</label>
<input id="rp-share-mail"
class="form-control"
placeholder="user@mail.com"
value="@_shareReceiverMail"
@oninput="args => _shareReceiverMail = args.Value?.ToString() ?? string.Empty" />
</div>
<div>
<label for="rp-reject-reason" class="form-label" style="font-size: 0.875rem; font-weight: 500; color: #495057;">
Grund <span style="color: #6c757d; font-weight: 400;">(optional)</span>
</label>
<textarea id="rp-reject-reason"
class="form-control"
rows="4"
maxlength="1000"
value="@_rejectReason"
@oninput="args => _rejectReason = args.Value?.ToString() ?? string.Empty"
placeholder="Grund der Ablehnung"
style="border: 2px solid #e9ecef; border-radius: 8px; padding: 0.625rem; resize: vertical;"></textarea>
<div class="mb-1">
<label class="form-label" for="rp-share-date" style="font-size: 0.9rem; font-weight: 500;">Gultig bis</label>
<input id="rp-share-date"
class="form-control"
type="date"
min="@DateTime.Today.AddDays(1).ToString("yyyy-MM-dd")"
max="@DateTime.Today.AddDays(90).ToString("yyyy-MM-dd")"
value="@_shareDateValid.ToString("yyyy-MM-dd")"
@oninput="OnShareDateChanged" />
</div>
@if (!string.IsNullOrWhiteSpace(_rejectValidationMessage))
@if (!string.IsNullOrWhiteSpace(_shareMessage))
{
<div style="background: #fee2e2; border-left: 4px solid #dc2626; padding: 0.75rem 1rem; margin-top: 1rem; border-radius: 4px;">
<span style="color: #b91c1c; font-size: 0.875rem; font-weight: 500;">@_rejectValidationMessage</span>
<div class="mt-3 alert @(_shareMessageIsError ? "alert-danger" : "alert-success") mb-0" role="alert">
@_shareMessage
</div>
}
</BodyContentTemplate>
<FooterContentTemplate>
<div class="d-flex gap-2 justify-content-end w-100" style="padding: 0.5rem 0;">
<div class="d-flex justify-content-end gap-2 w-100">
<button class="btn btn-outline-secondary"
@onclick="CloseRejectPopup"
disabled="@_isRejectSubmitting"
style="border-radius: 6px; padding: 0.625rem 1.25rem; font-weight: 500;">
Zurück
@onclick="CloseSharePopup"
disabled="@_isSharing">
Schliessen
</button>
<button class="btn btn-danger"
@onclick="SubmitRejectAsync"
disabled="@_isRejectSubmitting"
style="border-radius: 6px; padding: 0.625rem 1.5rem; font-weight: 600;">
@if (_isRejectSubmitting)
{
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
}
Ablehnen
<button class="btn btn-primary"
@onclick="ShareEnvelopeAsync"
disabled="@_isSharing"
style="background: linear-gradient(135deg, #4F46E5 0%, #4338CA 100%); border: none;">
@(_isSharing ? "Senden..." : "Senden")
</button>
</div>
</FooterContentTemplate>
@@ -468,12 +470,114 @@
string _signerFullName = string.Empty;
string _signerPosition = string.Empty;
string _signaturePlace = string.Empty;
bool _isLoggingOut = false;
// ----- Rejection popup state -----
bool _rejectPopupVisible;
bool _isRejectSubmitting;
string _rejectReason = string.Empty;
string? _rejectValidationMessage;
// ----- Share popup state -----
bool _sharePopupVisible = false;
bool _isSharing = false;
string _shareReceiverMail = string.Empty;
DateTime _shareDateValid = DateTime.Today.AddDays(7);
string? _shareMessage;
bool _shareMessageIsError = false;
async Task LogoutAsync()
{
if (_isLoggingOut || string.IsNullOrWhiteSpace(EnvelopeKey))
return;
_isLoggingOut = true;
await InvokeAsync(StateHasChanged);
try
{
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Receiver logout failed for {EnvelopeKey}", EnvelopeKey);
}
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
}
void OpenSharePopup()
{
_shareReceiverMail = string.Empty;
_shareDateValid = DateTime.Today.AddDays(7);
_shareMessage = null;
_shareMessageIsError = false;
_sharePopupVisible = true;
}
void CloseSharePopup()
{
if (_isSharing)
return;
_sharePopupVisible = false;
}
void OnShareDateChanged(Microsoft.AspNetCore.Components.ChangeEventArgs args)
{
if (DateTime.TryParse(args.Value?.ToString(), out var parsedDate))
_shareDateValid = parsedDate.Date;
}
async Task ShareEnvelopeAsync()
{
if (_isSharing)
return;
var email = _shareReceiverMail.Trim();
if (string.IsNullOrWhiteSpace(email) || !new EmailAddressAttribute().IsValid(email))
{
_shareMessage = "Bitte geben Sie eine gultige E-Mail-Adresse ein.";
_shareMessageIsError = true;
return;
}
var minDate = DateTime.Today.AddDays(1);
var maxDate = DateTime.Today.AddDays(90);
if (_shareDateValid.Date < minDate || _shareDateValid.Date > maxDate)
{
_shareMessage = "Bitte wahlen Sie ein Datum zwischen morgen und 90 Tagen.";
_shareMessageIsError = true;
return;
}
_isSharing = true;
_shareMessage = null;
_shareMessageIsError = false;
await InvokeAsync(StateHasChanged);
try
{
var shared = await AuthService.ShareEnvelopeReadOnlyAsync(EnvelopeKey!, email, _shareDateValid.Date);
if (shared)
{
_shareMessage = "Dokument wurde erfolgreich als Nur-Lesen freigegeben.";
_shareMessageIsError = false;
_shareReceiverMail = string.Empty;
_shareDateValid = DateTime.Today.AddDays(7);
}
else
{
_shareMessage = "Freigabe fehlgeschlagen. Bitte versuchen Sie es erneut.";
_shareMessageIsError = true;
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to share envelope in read-only mode for {EnvelopeKey}", EnvelopeKey);
_shareMessage = "Freigabe fehlgeschlagen. Bitte versuchen Sie es erneut.";
_shareMessageIsError = true;
}
finally
{
_isSharing = false;
await InvokeAsync(StateHasChanged);
}
}
// ----- Lifecycle -----
protected override async Task OnInitializedAsync()
@@ -485,6 +589,12 @@
return;
}
if (EnvelopeKey.TryDecode(out var decodedKeys) && decodedKeys.GetEncodeType() == EncodeType.EnvelopeReceiverReadOnly)
{
Navigation.NavigateTo($"/envelope/readonly/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
return;
}
// Authorization — same pattern as EnvelopeReceiverPage
_receiverUser = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
if (_receiverUser is null)
@@ -493,6 +603,12 @@
return;
}
if (await Mediator.IsSignedAsync(_receiverUser.EnvelopeUuid(), _receiverUser.ReceiverSignature()))
{
Navigation.NavigateTo("/envelope/signed-result", forceLoad: true);
return;
}
try
{
// Load PDF bytes via MediatR (uses authenticated user's claims)
@@ -511,11 +627,6 @@
_envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
if (_envelopeReceiver is null)
Logger.LogWarning("Envelope receiver data is null for {EnvelopeKey}", EnvelopeKey);
else if (IsRejected(_envelopeReceiver))
{
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}/rejected", forceLoad: true);
return;
}
// Build initial report (no signature image yet)
_report = BuildReport(_pdfBytes, _signatures, capturedSignature: null);
@@ -561,16 +672,6 @@
}
// ----- Report builder -----
static bool IsRejected(EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto envelopeReceiver)
{
var status = envelopeReceiver.Envelope?.Status;
if (status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected)
return true;
return envelopeReceiver.Envelope?.Histories?.Any(h =>
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) == true;
}
/// <summary>
/// Builds an XtraReport wrapping the PDF bytes.
/// If a signature is captured and there are signature fields, the signature image is
@@ -804,56 +905,6 @@
forceLoad: true);
}
void OpenRejectPopup()
{
_rejectValidationMessage = null;
_rejectPopupVisible = true;
}
void CloseRejectPopup()
{
if (_isRejectSubmitting)
return;
_rejectPopupVisible = false;
}
async Task SubmitRejectAsync()
{
if (_isRejectSubmitting)
return;
_isRejectSubmitting = true;
_rejectValidationMessage = null;
await InvokeAsync(StateHasChanged);
try
{
var reason = string.IsNullOrWhiteSpace(_rejectReason)
? null
: _rejectReason.Trim();
if (string.IsNullOrWhiteSpace(EnvelopeKey))
{
_rejectValidationMessage = "Envelope-Schluessel fehlt.";
_isRejectSubmitting = false;
await InvokeAsync(StateHasChanged);
return;
}
await AnnotationService.RejectAsync(EnvelopeKey, reason);
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to reject envelope {EnvelopeKey}", EnvelopeKey);
_rejectValidationMessage = "Die Ablehnung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.";
_isRejectSubmitting = false;
await InvokeAsync(StateHasChanged);
}
}
async Task<string?> GetActiveSignatureDataUrlAsync()
{
if (_activeSignatureTab == SignatureTabDraw)

View File

@@ -0,0 +1,188 @@
@page "/envelope/readonly/{ReadOnlyKey}"
@rendermode InteractiveServer
@using DevExpress.Blazor.Reporting
@using DevExpress.XtraReports.UI
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly
@using EnvelopeGenerator.Application.Common.Extensions
@using EnvelopeGenerator.Application.Common.Interfaces.Services
@using EnvelopeGenerator.Domain.Constants
@inject EnvelopeGenerator.Server.Client.Services.AppVersionService AppVersion
@inject ILogger<ReceiverReadOnlyPage> Logger
@inject IEnvelopeReceiverReadOnlyService ReadOnlyService
@inject IEnvelopeReceiverService EnvelopeReceiverService
@inject IEnvelopeHistoryService HistoryService
@implements IDisposable
<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" />
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
<div class="envelope-viewer-layout">
<div class="envelope-action-bar">
<div class="envelope-action-bar__inner" style="padding: 0.5rem 1.25rem;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:1rem;">
<div style="font-weight:600; color:#1f2937;">Dokumentansicht (Nur Lesen)</div>
@if (_readOnly is not null)
{
<div style="font-size:0.8rem; color:#6b7280;">Gultig bis: @_readOnly.DateValid.ToString("dd.MM.yyyy")</div>
}
</div>
</div>
</div>
<div class="envelope-content" style="padding:0; overflow:hidden;">
@if (_isLoading)
{
<div class="d-flex justify-content-center align-items-center h-100">
<div class="text-center">
<div class="spinner-border text-white mb-3" style="width:3.5rem; height:3.5rem;" role="status"></div>
<p class="text-white fw-semibold">Dokument wird geladen...</p>
</div>
</div>
}
else if (!string.IsNullOrWhiteSpace(_errorMessage))
{
<div class="error-container">
<div class="alert alert-danger shadow-lg mb-0">
<h5 class="mb-2">Dokument nicht verfugbar</h5>
<p class="mb-0">@_errorMessage</p>
</div>
</div>
}
else if (_isExpired)
{
<div class="error-container">
<div class="alert alert-warning shadow-lg mb-0">
<h5 class="mb-2">Freigabe abgelaufen</h5>
<p class="mb-0">Der Zeitraum fur diese Nur-Lesen-Freigabe ist abgelaufen.</p>
</div>
</div>
}
else if (_report is not null)
{
<DxReportViewer @ref="_reportViewer"
Report="_report"
RootCssClasses="w-100 h-100" />
}
</div>
</div>
@code {
[Parameter] public string? ReadOnlyKey { get; set; }
bool _isLoading = true;
bool _isExpired = false;
string? _errorMessage;
EnvelopeReceiverReadOnlyDto? _readOnly;
DxReportViewer? _reportViewer;
XtraReport? _report;
protected override async Task OnInitializedAsync()
{
if (string.IsNullOrWhiteSpace(ReadOnlyKey))
{
_errorMessage = "Freigabe-Schlussel fehlt.";
_isLoading = false;
return;
}
if (!ReadOnlyKey.TryDecode(out var decodedKeys) || decodedKeys.GetEncodeType() != EncodeType.EnvelopeReceiverReadOnly)
{
_errorMessage = "Ungultiger Freigabe-Link.";
_isLoading = false;
return;
}
try
{
var readOnlyId = decodedKeys.ParseReadOnlyId();
var readOnlyRes = await ReadOnlyService.ReadByIdAsync(readOnlyId);
if (readOnlyRes.IsFailed)
{
_errorMessage = "Freigabe konnte nicht geladen werden.";
_isLoading = false;
return;
}
_readOnly = readOnlyRes.Data;
if (DateTime.Now > _readOnly.DateValid)
{
_isExpired = true;
_isLoading = false;
return;
}
if (_readOnly.Envelope is null || _readOnly.Receiver is null)
{
_errorMessage = "Freigabe ist unvollstandig.";
_isLoading = false;
return;
}
var erRes = await EnvelopeReceiverService.ReadByUuidSignatureAsync(_readOnly.Envelope.Uuid, _readOnly.Receiver.Signature);
if (erRes.IsFailed)
{
_errorMessage = "Dokument wurde nicht gefunden.";
_isLoading = false;
return;
}
var envelopeReceiver = erRes.Data;
var documentBytes = envelopeReceiver.Envelope?.Documents?.FirstOrDefault()?.ByteData;
if (documentBytes is not { Length: > 0 })
{
_errorMessage = "Dokumentdaten konnten nicht geladen werden.";
_isLoading = false;
return;
}
_report = BuildReport(documentBytes);
try
{
var historyResult = await HistoryService.RecordAsync((int)_readOnly.EnvelopeId, _readOnly.AddedWho, EnvelopeStatus.EnvelopeViewed);
if (historyResult.IsFailed)
Logger.LogWarning("Failed to record EnvelopeViewed history for read-only key {ReadOnlyKey}", ReadOnlyKey);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to record read-only history for {ReadOnlyKey}", ReadOnlyKey);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Unexpected error in read-only page for {ReadOnlyKey}", ReadOnlyKey);
_errorMessage = "Beim Laden ist ein unerwarteter Fehler aufgetreten.";
}
_isLoading = false;
}
static XtraReport BuildReport(byte[] pdfBytes)
{
var report = new XtraReport
{
PaperKind = DevExpress.Drawing.Printing.DXPaperKind.A4,
Landscape = false,
Margins = new System.Drawing.Printing.Margins(0, 0, 0, 0)
};
var detail = new DetailBand { HeightF = 0f };
report.Bands.Add(detail);
detail.Controls.Add(new XRPdfContent
{
Source = pdfBytes,
GenerateOwnPages = true
});
return report;
}
public void Dispose()
{
_report?.Dispose();
}
}

View File

@@ -1,105 +0,0 @@
@page "/envelope/{EnvelopeKey}/rejected"
@rendermode InteractiveServer
@using EnvelopeGenerator.Application.Resources
@using EnvelopeGenerator.Domain.Constants
@using EnvelopeGenerator.Domain.Interfaces
@using Microsoft.Extensions.Localization
@inject NavigationManager Navigation
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
@inject IStringLocalizer<Resource> UiLocalizer
<div class="envelope-viewer-layout" style="justify-content: center; align-items: center; padding: 1.5rem;">
<div style="width: min(920px, 100%); background: rgba(255,255,255,0.96); border-radius: 20px; box-shadow: 0 18px 48px rgba(15,23,42,0.22); overflow: hidden;">
<div style="padding: 1.2rem 1.4rem; border-bottom: 1px solid rgba(15,23,42,0.08); background: linear-gradient(135deg, rgba(239,68,68,0.12) 0%, rgba(220,38,38,0.08) 100%);">
<div style="font-size: 1rem; font-weight: 700; color: #991b1b;">@L("DocRejected", "Dokument abgelehnt")</div>
</div>
<div style="padding: 2rem 1.5rem;">
<div style="display: flex; justify-content: center; margin-bottom: 1rem;">
<div style="width: 90px; height: 90px; border-radius: 999px; background: #fee2e2; display: inline-flex; align-items: center; justify-content: center; color: #b91c1c;">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
</svg>
</div>
</div>
<h2 style="text-align: center; font-size: 1.4rem; color: #7f1d1d; margin-bottom: 0.75rem;">
@(_isReadAndConfirm
? L("RejectionInfo1Confirmation", "Dieses Dokument wurde als nicht bestaetigt markiert.")
: L("RejectionInfo1", "Dieses Dokument wurde abgelehnt."))
</h2>
<p style="text-align: center; margin: 0 auto; max-width: 760px; color: #374151; line-height: 1.6;">
@((MarkupString)_messageLine2)
</p>
<div style="margin-top: 1.4rem; text-align: center;">
<a href="/" class="btn btn-outline-secondary" style="font-weight: 600;">Portal Startseite</a>
</div>
</div>
</div>
</div>
@code {
[Parameter] public string? EnvelopeKey { get; set; }
bool _isReadAndConfirm;
string _messageLine2 = string.Empty;
protected override async Task OnInitializedAsync()
{
if (string.IsNullOrWhiteSpace(EnvelopeKey))
{
Navigation.NavigateTo("/");
return;
}
var user = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
if (user is null)
{
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}");
return;
}
var envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
if (envelopeReceiver is null)
{
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
return;
}
if (!IsRejected(envelopeReceiver))
{
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
return;
}
_isReadAndConfirm = envelopeReceiver.Envelope?.IsReadAndConfirm() ?? false;
var senderName = envelopeReceiver.Envelope?.User is null
? string.Empty
: $"{envelopeReceiver.Envelope.User.Prename} {envelopeReceiver.Envelope.User.Name}".Trim();
var senderEmail = envelopeReceiver.Envelope?.User?.Email ?? string.Empty;
var envelopeTitle = envelopeReceiver.Envelope?.Title ?? string.Empty;
var template = L("RejectionInfo2", "Das Dokument \"{2}\" von {0} ({1}) wurde abgelehnt.");
_messageLine2 = string.Format(template, senderName, senderEmail, envelopeTitle);
}
static bool IsRejected(EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto envelopeReceiver)
{
var status = envelopeReceiver.Envelope?.Status;
if (status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected)
return true;
return envelopeReceiver.Envelope?.Histories?.Any(h =>
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) == true;
}
string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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";
}
}

View File

@@ -100,12 +100,11 @@ public class AnnotationController : ControllerBase
/// <summary>
/// Rejects the document for the current receiver.
/// </summary>
/// <param name="envelopeKey">Envelope key route parameter.</param>
/// <param name="reason">Optional rejection reason.</param>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost("reject/{envelopeKey}")]
[HttpPost("reject")]
[Obsolete("Use MediatR")]
public async Task<IActionResult> Reject([FromRoute] string envelopeKey, [FromBody] string? reason = null)
public async Task<IActionResult> Reject([FromBody] string? reason = null)
{
var signature = User.ReceiverSignature();
var uuid = User.EnvelopeUuid();

View File

@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Server.Controllers;
/// <summary>
/// Redirects legacy envelope key links to Blazor routes.
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
[Route("EnvelopeKey")]
public class EnvelopeKeyRedirectController : Controller
{
/// <summary>
/// Redirects /EnvelopeKey/{*path} links to /envelope/{path}.
/// </summary>
[HttpGet("{*path}")]
public IActionResult RedirectToEnvelopePath([FromRoute] string path)
=> Redirect($"/envelope/{path}");
}

View File

@@ -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);
}
}

View File

@@ -1,8 +1,10 @@
using DigitalData.Core.Abstraction.Application.DTO;
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Server.Extensions;
using MediatR;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
@@ -33,24 +35,36 @@ public class ReadOnlyController : ControllerBase
}
/// <summary>
/// Creates a new read-only receiver for the current envelope.
/// Creates a new read-only receiver for an envelope.
/// </summary>
/// <param name="envelopeKey">Envelope key (uuid+signature encoded).</param>
/// <param name="createDto">Creation payload.</param>
[HttpPost]
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost("{envelopeKey}")]
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
[Obsolete("Use MediatR")]
public async Task<IActionResult> CreateAsync([FromBody] EnvelopeReceiverReadOnlyCreateDto createDto)
public async Task<IActionResult> CreateAsync(
[FromRoute] string envelopeKey,
[FromBody] EnvelopeReceiverReadOnlyCreateDto createDto,
[FromServices] IMediator mediator,
CancellationToken cancel)
{
var authReceiverMail = User.ReceiverMail();
if (authReceiverMail is null)
var envelopeReceiver = await mediator.Send(new ReadEnvelopeReceiverQuery { Key = envelopeKey }, cancel);
var authEnvelopeReceiver = envelopeReceiver.FirstOrDefault();
if (authEnvelopeReceiver?.Envelope is null)
return NotFound();
var addedWho = User.FindFirstValue(ClaimTypes.Email)
?? authEnvelopeReceiver.Receiver?.EmailAddress;
if (string.IsNullOrWhiteSpace(addedWho))
{
_logger.LogError("EmailAddress claim is not found in envelope-receiver-read-only creation process. Create DTO is:\n {dto}", JsonConvert.SerializeObject(createDto));
return Unauthorized();
_logger.LogError("Receiver e-mail is missing in envelope-receiver-read-only creation process. Create DTO is:\n {dto}", JsonConvert.SerializeObject(createDto));
return StatusCode(StatusCodes.Status500InternalServerError);
}
var envelopeId = User.EnvelopeId();
var envelopeId = authEnvelopeReceiver.EnvelopeId;
createDto.AddedWho = authReceiverMail;
createDto.AddedWho = addedWho;
createDto.EnvelopeId = envelopeId;
var creationRes = await _readOnlyService.CreateAsync(createDto: createDto);

View File

@@ -10,9 +10,9 @@
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>EnvelopeGenerator.Server</Product>
<Version>1.2.0-beta</Version>
<FileVersion>1.2.0.0</FileVersion>
<AssemblyVersion>1.2.0.0</AssemblyVersion>
<Version>1.4.0-beta</Version>
<FileVersion>1.4.0.0</FileVersion>
<AssemblyVersion>1.4.0.0</AssemblyVersion>
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
</PropertyGroup>
@@ -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>

View File

@@ -67,7 +67,7 @@ public static class ReceiverClaimExtensions
/// Gets the authenticated receiver email address from the claims.
/// </summary>
public static string ReceiverMail(this ClaimsPrincipal user)
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email, ClaimTypes.Email);
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email);
/// <summary>
/// Gets the authenticated envelope identifier from the claims.
@@ -120,4 +120,4 @@ public static class ReceiverClaimExtensions
{
return Role.Receiver.Authorized.Any(user.IsInRole);
}
}
}

View File

@@ -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>();

View File

@@ -56,6 +56,351 @@
height: 100%;
}
.sender-image-editor {
overflow: auto;
padding: 1rem;
}
.sender-image-editor-shell {
position: relative;
width: 100%;
height: 100%;
}
.sender-image-editor__toolbar-host {
position: sticky;
top: 0;
z-index: 40;
height: 0;
pointer-events: none;
}
.sender-image-toolbar {
pointer-events: auto;
position: absolute;
top: 0.75rem;
left: 50%;
transform: translateX(-50%);
width: calc(100% - 2rem);
max-width: 1040px;
margin: 0;
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr) auto;
gap: 0.6rem;
align-items: center;
background: transparent;
}
.sender-image-toolbar__group {
min-height: 3.35rem;
background: #ffffff;
border: 1px solid #d6deef;
border-radius: 0.55rem;
padding: 0.38rem 0.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.28rem;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.09);
}
.sender-image-toolbar__group--nav {
justify-content: flex-start;
}
.sender-image-toolbar__group--zoom {
justify-content: center;
gap: 0.4rem;
}
.sender-image-toolbar__group--actions {
justify-content: center;
gap: 0.4rem;
padding-inline: 0.6rem;
}
.sender-image-toolbar__icon-btn {
width: 2.25rem;
height: 2.25rem;
border: 0;
border-radius: 0.45rem;
background: transparent;
color: #1f2a44;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 0.18s ease, color 0.18s ease, transform 0.18s ease;
}
.sender-image-toolbar__icon-btn:hover:not(:disabled) {
background: #eef3ff;
color: #0f3f99;
}
.sender-image-toolbar__icon-btn:active:not(:disabled) {
transform: translateY(1px);
}
.sender-image-toolbar__icon-btn:focus-visible {
outline: 2px solid #3468d6;
outline-offset: 1px;
}
.sender-image-toolbar__icon-btn:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.sender-image-toolbar__icon-btn--caret {
width: 1.95rem;
}
.sender-image-toolbar__icon-btn svg {
width: 19px;
height: 19px;
}
.sender-image-toolbar__icon-btn--busy svg {
animation: sender-toolbar-spin 0.95s linear infinite;
}
.sender-image-toolbar__select-wrap {
position: relative;
width: 1.95rem;
height: 2.25rem;
}
.sender-image-toolbar__page-select {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
border: 0;
}
.sender-image-toolbar__page-select:disabled {
cursor: not-allowed;
}
.sender-image-toolbar__value-box {
min-width: 7.8rem;
height: 2.25rem;
border: 1px solid #d5dceb;
border-radius: 0.45rem;
background: #f8fafc;
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 0.35rem;
padding: 0 0.65rem;
color: #233252;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.sender-image-toolbar__value-box:focus-within {
border-color: #7ea2ee;
box-shadow: 0 0 0 2px rgba(126, 162, 238, 0.2);
}
.sender-image-toolbar__page-input {
width: 2.3rem;
border: 0;
outline: none;
background: transparent;
color: inherit;
font-size: inherit;
font-weight: inherit;
text-align: right;
-moz-appearance: textfield;
}
.sender-image-toolbar__page-input::-webkit-outer-spin-button,
.sender-image-toolbar__page-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.sender-image-toolbar__page-total {
color: #5b6c8f;
font-size: 0.68rem;
font-weight: 600;
}
.sender-image-toolbar__zoom-slider {
-webkit-appearance: none;
appearance: none;
width: clamp(130px, 20vw, 190px);
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, #d7e2ff 0%, #84a9ff 100%);
outline: none;
cursor: pointer;
}
.sender-image-toolbar__zoom-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 15px;
height: 15px;
border-radius: 50%;
background: #2f5fc9;
box-shadow: 0 1px 4px rgba(47, 95, 201, 0.35);
}
.sender-image-toolbar__zoom-slider::-moz-range-thumb {
width: 15px;
height: 15px;
border: 0;
border-radius: 50%;
background: #2f5fc9;
box-shadow: 0 1px 4px rgba(47, 95, 201, 0.35);
}
.sender-image-toolbar__zoom-slider:focus-visible {
outline: 2px solid #3468d6;
outline-offset: 2px;
}
@keyframes sender-toolbar-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.sender-image-editor__pages {
max-width: 1040px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
padding-top: 5.15rem;
}
.sender-image-editor__native-preview {
max-width: 1040px;
margin: 5.15rem 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: move;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.35rem;
overflow: hidden;
}
.sender-image-editor__field:active {
cursor: move;
}
.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%;
@@ -609,29 +954,6 @@ body.resizing {
cursor: not-allowed;
}
.pdf-toolbar__btn--reject {
display: flex;
align-items: center;
gap: 0.375rem;
min-width: auto;
padding: 0.5rem 0.75rem;
background: linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(220, 38, 38, 0.08) 100%);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #b91c1c;
}
.pdf-toolbar__btn--reject:hover:not(:disabled) {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
border-color: transparent;
color: #ffffff;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
.pdf-toolbar__btn--reject:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.pdf-toolbar__btn-text {
font-size: 0.813rem;
font-weight: 600;
@@ -1065,6 +1387,41 @@ body.resizing {
padding: 0.75rem;
}
.sender-image-toolbar {
grid-template-columns: 1fr;
width: calc(100% - 1rem);
top: 0.5rem;
gap: 0.45rem;
}
.sender-image-toolbar__group {
min-height: 3.15rem;
justify-content: center;
flex-wrap: wrap;
}
.sender-image-editor__pages {
padding-top: 11.25rem;
}
.sender-image-editor__native-preview {
margin-top: 11.25rem;
}
.sender-image-toolbar__value-box {
min-width: 6.7rem;
height: 2rem;
font-size: 0.69rem;
}
.sender-image-toolbar__zoom-slider {
width: min(44vw, 220px);
}
.sender-image-toolbar__group--actions {
justify-content: flex-end;
}
.pdf-thumbnails {
width: 180px;
border-radius: 0 0 0 16px;
@@ -1234,18 +1591,6 @@ body.resizing {
color: #a5b4fc;
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--reject {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(248, 113, 113, 0.4);
color: #fca5a5;
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--reject:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.35);
border-color: rgba(248, 113, 113, 0.6);
color: #ffffff;
}
/* Error container */
[data-sf-theme="blazing-dark"] .error-container .alert-danger {
background: rgba(239, 68, 68, 0.12);

View File

@@ -1,5 +1,11 @@
window.envelopeEditor = {
_moveThrottle: {
rafId: 0,
latest: null,
waiters: []
},
/**
* Returns the click position normalised to [0,1] relative to the rendered PDF page
* element inside DxPdfViewer (or DxReportViewer as fallback).
@@ -25,9 +31,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',
@@ -89,7 +96,229 @@ window.envelopeEditor = {
return {
normX: normX,
normY: normY,
pageIndex: targetIndex
pageIndex: targetIndex,
pageNumber: parseInt(targetPage.getAttribute('data-editor-page') || String(targetIndex + 1), 10)
};
},
getClickCoordsOnPdfPageThrottled: function (viewerCssClass, clientX, clientY) {
return new Promise((resolve) => {
const state = window.envelopeEditor._moveThrottle;
state.latest = { viewerCssClass, clientX, clientY };
state.waiters.push(resolve);
if (state.rafId !== 0) {
return;
}
state.rafId = window.requestAnimationFrame(() => {
state.rafId = 0;
const args = state.latest;
state.latest = null;
const result = args
? window.envelopeEditor.getClickCoordsOnPdfPage(args.viewerCssClass, args.clientX, args.clientY)
: null;
const waiters = state.waiters.splice(0, state.waiters.length);
for (const waiter of waiters) {
waiter(result);
}
});
});
},
scrollToPdfPage: function (viewerCssClass, pageNumber) {
const viewer = document.querySelector('.' + viewerCssClass);
if (!viewer) {
return;
}
const targetPageNumber = Math.max(1, Number(pageNumber) || 1);
const page = viewer.querySelector('[data-editor-page="' + targetPageNumber + '"]');
if (!page) {
return;
}
const viewerRect = viewer.getBoundingClientRect();
const pageRect = page.getBoundingClientRect();
const targetScrollTop = viewer.scrollTop + (pageRect.top - viewerRect.top) - 8;
viewer.scrollTo({
top: Math.max(0, targetScrollTop),
behavior: 'smooth'
});
},
getVisiblePdfPageNumber: function (viewerCssClass) {
const viewer = document.querySelector('.' + viewerCssClass);
if (!viewer) {
return null;
}
const pages = Array.from(viewer.querySelectorAll('[data-editor-page]'));
if (pages.length === 0) {
return null;
}
const viewerRect = viewer.getBoundingClientRect();
let bestPage = null;
let bestVisibleHeight = -1;
for (const page of pages) {
const rect = page.getBoundingClientRect();
const visibleTop = Math.max(rect.top, viewerRect.top);
const visibleBottom = Math.min(rect.bottom, viewerRect.bottom);
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
if (visibleHeight > bestVisibleHeight) {
bestVisibleHeight = visibleHeight;
bestPage = page;
}
}
if (!bestPage) {
return null;
}
const raw = bestPage.getAttribute('data-editor-page');
const parsed = Number.parseInt(raw || '0', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
},
scrollPdfViewerByDelta: function (viewerCssClass, deltaY) {
const viewer = document.querySelector('.' + viewerCssClass);
if (!viewer) {
return;
}
viewer.scrollTop += Number(deltaY) || 0;
},
ensureZoomOverride: function (viewerCssClass) {
const viewer = document.querySelector('.' + viewerCssClass);
if (!viewer) {
return;
}
if (viewer.dataset.zoomOverrideAttached === '1') {
return;
}
viewer.dataset.zoomOverrideAttached = '1';
viewer.addEventListener('wheel', function (event) {
event.preventDefault();
}, { passive: false });
viewer.addEventListener('keydown', function (event) {
if (!event.ctrlKey) {
return;
}
const key = event.key;
if (key === '+' || key === '=' || key === '-' || key === '0') {
event.preventDefault();
}
});
viewer.addEventListener('touchmove', function () {
}, { passive: true });
},
downloadDataUrlAsFile: function (dataUrl, fileName, mimeType) {
if (!dataUrl || typeof dataUrl !== 'string') {
return;
}
const downloadName = fileName || 'document.pdf';
const link = document.createElement('a');
link.href = dataUrl;
link.download = downloadName;
link.rel = 'noopener';
const host = document.body || document.documentElement;
host.appendChild(link);
link.click();
host.removeChild(link);
},
printDataUrlPdf: async function (dataUrl, mimeType) {
if (!dataUrl || typeof dataUrl !== 'string') {
return;
}
let objectUrl = null;
let printSrc = dataUrl;
if (dataUrl.startsWith('data:')) {
try {
const response = await fetch(dataUrl);
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
printSrc = objectUrl;
} catch {
printSrc = dataUrl;
}
}
const frame = document.createElement('iframe');
frame.style.position = 'fixed';
frame.style.right = '0';
frame.style.bottom = '0';
frame.style.width = '0';
frame.style.height = '0';
frame.style.border = '0';
frame.setAttribute('aria-hidden', 'true');
const host = document.body || document.documentElement;
host.appendChild(frame);
let cleaned = false;
const cleanup = function () {
if (cleaned) {
return;
}
cleaned = true;
if (frame.parentNode) {
frame.parentNode.removeChild(frame);
}
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
objectUrl = null;
}
};
frame.onload = function () {
try {
const frameWindow = frame.contentWindow;
if (!frameWindow) {
cleanup();
return;
}
frameWindow.addEventListener('afterprint', function () {
window.setTimeout(cleanup, 250);
}, { once: true });
window.addEventListener('focus', function () {
window.setTimeout(cleanup, 1500);
}, { once: true });
frameWindow.focus();
frameWindow.print();
} catch {
cleanup();
}
};
frame.src = printSrc;
window.setTimeout(cleanup, 120000);
}
};

View File

@@ -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}

View 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.

View File

@@ -0,0 +1,86 @@
# Sender PDF Toolbar Migration Plan
## Goal
Design and integrate a reusable toolbar component for `SenderPdfImageEditor` that matches the provided visual style and supports:
- page navigation
- zoom in/out (including overriding default mouse-based zoom behavior inside editor)
- PDF download as original bytes (not image)
- print output
Critical rule: existing signature add/remove/drag-drop behavior must remain fully functional.
## Scope and Files
Planned touch points:
1. `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/SenderPdfImageEditorToolbar.razor` (new)
2. `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/SenderPdfImageEditorToolbar.razor.cs` (optional, if code-behind needed)
3. `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/SenderPdfImageEditor.razor` (compose toolbar + editor integration)
4. `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/SenderEditorPage.razor` (pass PDF bytes/metadata/events to editor)
5. `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css` (toolbar visual styles + responsive)
6. `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/envelope-editor.js` (helper functions: scroll-to-page, wheel handling, download, print if needed)
## Non-Regression Strategy
- Keep all existing signature event paths unchanged (`EditorClick`, `MoveField`, `RemoveField`).
- Introduce toolbar as a separate component with explicit parameters and callbacks.
- Add features incrementally; no behavior switch all at once.
- After each stage: run manual black-box checks focused on signatures and new feature.
- If any regression appears: rollback only the stage-local changes.
## Stage Plan
### Stage 1 - Visual Toolbar Skeleton (all buttons disabled)
- Build standalone `SenderPdfImageEditorToolbar` component.
- Recreate layout from reference image (three grouped blocks: page nav, zoom, print/download).
- Render in `SenderPdfImageEditor` above page canvas area.
- All controls disabled; only visual state and responsive behavior implemented.
- No functional binding yet.
Acceptance:
- Toolbar appears and matches structure/spacing/icons closely.
- Signature add/remove/drag still works exactly as before.
### Stage 2 - Page Navigation
- Add current page/total page model and callbacks.
- Implement first/prev/next/last navigation.
- Implement page number selector UI behavior.
- Navigate by scrolling/focusing target page container (`data-editor-page`) without changing signature logic.
Acceptance:
- Navigation controls move to correct page boundaries.
- Signature interactions continue to work on all pages.
### Stage 3 - Zoom In/Out + Mouse Override
- Introduce zoom state (e.g., 50%-300%, step-based).
- Wire `-` / `+` and percent display.
- Apply zoom in a way that preserves coordinate normalization for signature placement/drag.
- Override wheel behavior inside editor container (`@onwheel:preventDefault` + JS helper where needed).
Acceptance:
- Zoom buttons work consistently.
- Mouse wheel behavior follows custom zoom logic.
- Signature placement and drag coordinates remain correct after zoom changes.
### Stage 4 - Download Original PDF Bytes
- Expose download action from toolbar.
- Download source must be original PDF bytes (same content as uploaded/loaded document).
- Suggested filename from current editor state.
Acceptance:
- Downloaded file opens as valid PDF.
- Not an image export; true PDF bytes.
### Stage 5 - Print Output
- Add print action from toolbar.
- Print original PDF content using browser print flow (hidden iframe/object URL approach).
- Ensure action is isolated and does not mutate editor state.
Acceptance:
- Print dialog opens with correct document.
- Existing editor/signature interactions remain intact before and after print.
## Approval Gates
- Stop after each stage.
- Share what changed + exact black-box checklist.
- Wait for explicit approval before moving to next stage.
## Notes
- [ ] Toolbar visual polish is pending (icon sizing/balance/spacing fine-tuning).