Enhanced EnvelopeViewer with a thumbnail sidebar for page previews and a resizable splitter (150px-400px range) for improved navigation. Updated layout to use a flexbox design for side-by-side thumbnails and PDF canvas. Externalized CSS for maintainability and added responsive behavior for mobile devices. Improved Blazor lifecycle handling with `firstRender` checks and sequential thumbnail rendering. Addressed known issues like vertical alignment and infinite render loops. Introduced localStorage persistence for user preferences and enhanced zoom/navigation interactivity with global mouse events.
18 KiB
EnvelopeGenerator — Copilot Context Notes (English)
Purpose
A digital document signing system. Senders upload PDFs and place signature annotation fields via PSPDFKit (EnvelopeGenerator.Web). Receivers open the document in a Blazor WASM viewer, confirm each signature field via a checkbox overlay, draw/type/upload their signature, and export the stamped PDF.
Solution Structure
| Project | Target | Description |
|---|---|---|
EnvelopeGenerator.API |
net8.0 | ASP.NET Core Web API. Receiver auth (cookie), annotation reading, PDF serving. |
EnvelopeGenerator.ReceiverUI |
net8.0 WASM | Blazor WebAssembly. Receiver UI. YARP proxies API calls. |
EnvelopeGenerator.Web |
net7/8/9 | Razor Pages. Sender UI + PSPDFKit annotation placement. |
EnvelopeGenerator.Application |
multi | MediatR CQRS handlers. |
EnvelopeGenerator.Domain |
multi | Domain models, constants, interfaces. |
EnvelopeGenerator.Infrastructure |
multi | EF Core repos, DB context. |
EnvelopeGenerator.PdfEditor |
multi | iText7 utilities. NOT used in ReceiverUI flow. |
EnvelopeGenerator.DependencyInjection |
multi | DI registration helpers. |
| VB.NET projects (Service/Form/BBTests) | net462 | Legacy. Do NOT touch. |
Key Files
| File | Purpose |
|---|---|
ReceiverUI/Pages/EnvelopeViewer.razor |
NEW PDF.js-based viewer (/envelope/{key}). Replaces ReportViewer.razor. Simple read-only PDF viewing with zoom/navigation. |
ReceiverUI/Pages/ReportViewer.razor |
LEGACY DevExpress-based signing page (/receiver/{key}). Still used for signature workflow. Will be deprecated. |
ReceiverUI/wwwroot/js/pdf-viewer.js |
NEW PDF.js wrapper: rendering, zoom, pagination, mouse wheel control. |
ReceiverUI/wwwroot/js/receiver-signature.js |
JS: checkbox overlay, signature pad (draw/type/image). |
ReceiverUI/wwwroot/css/envelope-viewer.css |
NEW Styles for EnvelopeViewer.razor (external CSS, not inline). |
ReceiverUI/wwwroot/fake-data/annotations.json |
Dev-mode fake annotations (YARP proxy target). |
ReceiverUI/Models/AnnotationDto.cs |
Annotation position model. All properties non-nullable. |
ReceiverUI/Services/AnnotationService.cs |
Fetches List<AnnotationDto> from API or fake-data. |
ReceiverUI/Services/DocumentService.cs |
Fetches PDF bytes from API. |
ReceiverUI/Services/AuthService.cs |
Manages receiver session cookie. |
API/Controllers/AnnotationController.cs |
GET api/Annotation/{key} ? annotation list. |
API/Controllers/DocumentController.cs |
GET api/Document/{key} ? PDF bytes. |
AnnotationDto — Coordinate System
Unit : 1/100 inch (DX units) — DevExpress XtraReports native
Origin : Top-left corner of page
X : increases rightward
Y : increases downward
A4 in DX units: Width = 827, Height = 1169
Conversions:
PSPDFKit (pt, top-left): xDX = xPsPdf * (100/72)
GDPicture (pt, bottom-left): yDX = (pageHeightPt - yGD - elemHeightPt) * (100/72)
DX ? PDF points: pt = dx * (72/100)
EnvelopeViewer (NEW) — PDF.js Read-Only Viewer
Route: /envelope/{EnvelopeKey}
Purpose: Simple, modern PDF viewing without signing functionality.
Technology: PDF.js 3.11.174 + custom JavaScript wrapper
Architecture
Blazor Component (EnvelopeViewer.razor):
- Fetches PDF via
DocumentService.GetDocumentAsync(EnvelopeKey) - Converts to base64 data URL:
data:application/pdf;base64,{base64} - Initializes PDF.js viewer via JSInterop with
DotNetObjectReferencefor callbacks - Displays controls: Zoom In/Out, Page Navigation, Zoom percentage, Thumbnail toggle
- Thumbnail sidebar with resizable splitter (150px-400px range)
- CSS externalized to
envelope-viewer.css
JavaScript (pdf-viewer.js):
window.pdfViewer = {
pdfDoc, canvas, ctx, scale, currentRenderTask,
dotNetReference, wheelEventAttached,
isResizing, resizeMouseMoveHandler, resizeMouseUpHandler,
initialize(canvasId, pdfDataUrl, dotNetRef),
renderPage(num),
renderThumbnail(pageNum, canvasId),
attachWheelEvent(), // Global Ctrl+Wheel zoom
attachResizeListeners(dotNetRef), // Splitter resize
detachResizeListeners(),
startResize(),
zoomIn(), zoomOut(),
nextPage(), previousPage(),
dispose()
}
CSS (envelope-viewer.css):
.envelope-viewer-layout: Full-height gradient background.envelope-action-bar: Top bar with logo, title, controls (sticky).pdf-frame: Flex container (row) with thumbnails + canvas side-by-side.pdf-thumbnails: Left sidebar (260px default, resizable 150-400px), no header.pdf-splitter: 4px resizable divider withcol-resizecursor.pdf-canvas-wrapper: Flex-grow container with scroll, padding, centered canvas.pdf-canvas:display: inline-block, unlimited zoom, scrollable when exceeds frame- Modern glassmorphism design with gradients and shadows
Features
-
Unlimited Zoom:
- Canvas size not restricted by
max-width - Frame stays fixed, scroll bars appear automatically
text-align: centerfor small sizes, full scroll for zoomed views
- Canvas size not restricted by
-
Global Mouse Wheel Zoom:
- Event listener on
document.body(works anywhere on page) Ctrl + Mouse WheeltriggerszoomIn()/zoomOut()- Calls
dotNetReference.invokeMethodAsync('OnZoomChanged', scale)to update Blazor UI { passive: false }to enablepreventDefault()
- Event listener on
-
Render Task Cancellation:
- Stores
currentRenderTaskto cancel previous render if new one starts - Catches
RenderingCancelledExceptionto avoid console errors - Queue system (
pageNumPending) for rapid page changes
- Stores
-
Thumbnail Sidebar:
- Left panel with page previews (sequential rendering, 50ms delay)
- Click to navigate to specific page
- Active page highlighted with gradient border
- No header/title (maximizes thumbnail space)
- Toggle button in toolbar to show/hide
-
Resizable Splitter:
- 4px draggable divider between thumbnails and canvas
- Min width: 150px, Max width: 400px
- Visual feedback: gradient on hover/active
- User preference saved to localStorage (
envelopeViewer_thumbnailWidth) - Global mouse events (works anywhere during drag)
col-resizecursor (?) for intuitive UX
-
Flex Layout:
- Thumbnails and canvas in same container (
.pdf-frame) display: flex, flex-direction: row, align-items: stretch- Perfect vertical alignment (same top/bottom position)
- Responsive: column layout on mobile (<768px)
- Thumbnails and canvas in same container (
-
Responsive Design:
- Desktop: 90% width, 1200px max
- Mobile: 95% width, adjusted heights, thumbnails collapse to top
- Adaptive padding and font sizes
Flow
-
Component Load:
OnInitializedAsync(): - Fetch PDF bytes - Convert to base64 data URL - Set _isLoading = false OnAfterRenderAsync(firstRender): - Load saved thumbnail width from localStorage - Create DotNetObjectReference - JSRuntime.InvokeAsync("pdfViewer.initialize", canvasId, pdfDataUrl, dotNetRef) - Attach resize listeners for splitter - Update _totalPages, _currentPage, _pdfLoaded - Render thumbnails sequentially (50ms delay between pages) -
User Interaction:
- Button clicks ?
ZoomIn()/ZoomOut()?JSRuntime.InvokeVoidAsync("pdfViewer.zoomIn") - Ctrl+Wheel ? JS
attachWheelEvent()?dotNetRef.invokeMethodAsync('OnZoomChanged') - Page buttons ?
NextPage()/PreviousPage()?JSRuntime.InvokeAsync("pdfViewer.nextPage") - Thumbnail click ?
GoToPageFromThumbnail(pageNum)?pdfViewer.goToPage(pageNum) - Toggle button ?
ToggleThumbnails()?_showThumbnails = !_showThumbnails - Splitter drag ?
OnSplitterMouseDown()? JS global mouse events ?OnSplitterMouseMove(clientX)? width update ?OnSplitterMouseUp()? save to localStorage
- Button clicks ?
-
Cleanup:
DisposeAsync(): - JSRuntime.InvokeVoidAsync("pdfViewer.dispose") - Detach resize listeners - _dotNetRef?.Dispose()
Key Differences from ReportViewer
| Feature | EnvelopeViewer (NEW) | ReportViewer (LEGACY) |
|---|---|---|
| Technology | PDF.js + Canvas | DevExpress XtraReports |
| Route | /envelope/{key} |
/receiver/{key} |
| Purpose | Read-only viewing | Signature workflow |
| Dependencies | PDF.js CDN | DevExpress NuGet packages |
| Zoom | Unlimited, smooth | Report viewer default |
| Mouse Wheel | Custom Ctrl+Wheel | Browser default |
| File Size | Minimal (JS + CSS) | Heavy (DX libs) |
| Maintenance | Simple, standard web | Complex, vendor-specific |
ReceiverUI Signing Flow (ReportViewer.razor — LEGACY)
On Load (OnInitializedAsync)
AuthService.CheckEnvelopeAccessAsync? redirect to login if unauthorizedAnnotationService.GetAnnotationsAsync? fills_annotationsDocumentService.GetDocumentAsync? fills_basePdfBytes(real mode)BuildFreshBaseReport()?XtraReportforDxReportViewer
Signature Popup
- Tabs: Draw / Text / Image
- Fields: full name (required), position (optional), place (required)
- Saved to
_capturedSignaturerecord - If annotations exist ? popup closes ? JS checkbox overlays installed
JS Checkbox Overlay (receiver-signature.js)
receiverSignature.installAnnotationCheckboxes(annotations, checkedIds, dotNetRef)- One
.annot-sig-cb-wrapperdiv per annotation, absolutely positioned over viewer scroll container - Position:
left = pageRect.left + ann.x * scaleX,top = pageRect.top + ann.y * scaleYscaleX = pagePixelWidth / 827,scaleY = pagePixelHeight / 1169
- Click ?
dotNetRef.invokeMethodAsync('OnAnnotationToggled', id, checked)
Apply Signatures ("Unterschriften anwenden" — SubmitSignaturesAsync)
Real PDF mode (_basePdfBytes is set):
- Calls
StampSignaturesOnPdfusing iText7 directly on PDF bytes - Coordinate conversion:
xPt = ann.X * (72/100),yPt = pageHeight - ann.Y * (72/100) - sigHeight(Y flip: DX top-down ? PDF bottom-up) - Returns stamped bytes ? loaded into new
XtraReportwithXRPdfContent - Viewer refreshed with
ViewerKey++
Dev/fake mode (_basePdfBytes is null):
- Falls back to
AddSignatureAtAnnotation(XtraReports DetailBand + BeforePrint counter) - This is intentionally left as a dev fallback only
Export
reportViewer.ExportToAsync(ExportFormat.Pdf)
StampSignaturesOnPdf — iText7 Implementation
// Located in ReportViewer.razor @code section
// Called by SubmitSignaturesAsync when _basePdfBytes is available
static byte[] StampSignaturesOnPdf(
byte[] sourcePdfBytes, byte[] signatureImageBytes,
string signerFullName, string signerPosition, string signaturePlace,
IReadOnlyList<AnnotationDto> annotations)
{
// Opens PDF with PdfReader/PdfWriter
// For each annotation:
// pageNum = ann.Page (clamped to totalPages)
// xPt = ann.X * (72f/100f)
// imgBottomY = pageHeight - ann.Y * (72f/100f) - sigHeightPt ? Y-axis flip
// PdfCanvas.AddImageFittedIntoRectangle(imageData, rect, false)
// Separator line at bottom of image
// Canvas text block below separator (font: Helvetica 7pt, color: RGB(73,80,87))
// Returns stamped byte[]
}
BuildFreshBaseReport()
// Real PDF mode:
var report = new XtraReport();
var detail = new DetailBand();
report.Bands.Add(detail);
detail.Controls.Add(new XRPdfContent { Source = _basePdfBytes, GenerateOwnPages = true });
return report;
// Dev/fake mode: returns pre-built report from ReportStorage
NuGet Packages in ReceiverUI
| Package | Version | Purpose |
|---|---|---|
DevExpress.Blazor.Reporting.Viewer |
25.2.3 | DxReportViewer (LEGACY, used in ReportViewer.razor) |
DevExpress.Blazor.PdfViewer |
25.2.3 | PDF viewer (not used in EnvelopeViewer) |
DevExpress.Drawing.Skia |
25.2.3 | Drawing backend |
itext |
8.0.5 | PDF stamping (iText7) |
SkiaSharp.* |
3.119.1 | WASM native rendering |
External CDN (EnvelopeViewer):
- PDF.js 3.11.174 (via
https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js) - PDF.js Worker (
pdf.worker.min.js)
Mistakes History — Do NOT Repeat
| Mistake | Why Wrong |
|---|---|
BottomMarginBand for per-page signatures |
Repeats on every page; Y offset wrong |
imageY = (page-1) * 1169 + ann.Y |
Inflates DetailBand; 35 pages ? 140 pages |
e.Graph?.PrintingSystem in BeforePrint |
Graph not on CancelEventArgs |
ctrl.Report?.PrintingSystem |
PrintingSystem not on XtraReportBase in WASM |
Adding stamp endpoint to DocumentController |
Not needed; stamping is done client-side in ReceiverUI |
| iText7 via API (server-side) | Unnecessary; iText7 runs fine in WASM directly |
PDF.js: display: flex on .pdf-frame |
Prevents left-edge scroll when canvas exceeds container |
PDF.js: max-width: 100% on canvas |
Limits zoom; user expects unlimited zoom capability |
Mouse wheel on .pdf-frame only |
Only works when mouse over PDF; should work anywhere on page |
OnAfterRenderAsync without firstRender guard |
Creates infinite loop when StateHasChanged is called repeatedly |
Conditional rendering with @if (_pdfLoaded) wrapping canvas |
Canvas not in DOM when initialize called, causing perpetual failure |
Thumbnail sidebar with position: absolute |
Independent from PDF canvas, breaks alignment on screen resize |
| Thumbnail header with title/close button | Wastes valuable space; toolbar toggle is sufficient |
DevExpress Article (2023-08-28) — Why It Does NOT Apply
The article describes X.509 cryptographic digital signatures via PdfDocumentSigner + Pkcs7Signer.
Our use case is visual/image stamping at specific page coordinates — different problem, different API.
XRPdfSignature in the article requires pre-placed fields in the report designer, not runtime coordinates.
Change Log
| Session | Date | Change |
|---|---|---|
| 1–3 | — | Core infrastructure: services, YARP proxy, JS overlay, signature pad |
| 4 | — | AddSignatureAtAnnotation with BottomMarginBand — ? repeated on all pages |
| 5 | — | BeforePrint + e.Graph?.PrintingSystem — ? compile error |
| 6 | — | BeforePrint counter — ? correct pattern, wrong band |
| 7 | — | Switched to DetailBand — ? correct band |
| 8 | — | (page-1)*1169+Y offset — ? 35?140 page inflation |
| 9 | — | Fixed: BoundsF.Y = ann.Y + counter; created COPILOT_CONTEXT.md |
| 10 | — | Investigated DevExpress article — not applicable to our case |
| 10 | — | Added iText7 to ReceiverUI; implemented StampSignaturesOnPdf — ? deterministic coordinates, no page count side effects |
| 10 | — | Split COPILOT_CONTEXT.md into COPILOT_CONTEXT_EN.md and COPILOT_CONTEXT_TR.md |
| 11 | 2025-01-XX | Created EnvelopeViewer.razor (/envelope/{key}) with PDF.js 3.11.174 |
| 11 | 2025-01-XX | Implemented pdf-viewer.js: canvas rendering, zoom, pagination, render task cancellation |
| 11 | 2025-01-XX | Externalized CSS to envelope-viewer.css: modern glassmorphism design |
| 11 | 2025-01-XX | Fixed scroll issues: removed display: flex, used text-align: center + inline-block |
| 11 | 2025-01-XX | Removed canvas max-width restriction for unlimited zoom |
| 11 | 2025-01-XX | Added global mouse wheel zoom: Ctrl+Wheel on document.body, JSInterop callback to Blazor |
| 11 | 2025-01-XX | Added PDF thumbnail sidebar (left panel) with page previews and navigation |
| 11 | 2025-01-XX | Implemented thumbnail rendering system with sequential loading (50ms delay between pages) |
| 11 | 2025-01-XX | Fixed thumbnail rendering: retry logic (10x 100ms) for canvas availability |
| 11 | 2025-01-XX | Refactored layout: Moved thumbnails inside pdf-frame for flex side-by-side design |
| 11 | 2025-01-XX | Removed thumbnail header (title + close button) to maximize thumbnail space |
| 11 | 2025-01-XX | Added resizable splitter: 4px draggable divider, 150-400px range, localStorage persistence |
| 11 | 2025-01-XX | Fixed vertical alignment: align-items: stretch ensures thumbnails and canvas have same height |
| 11 | 2025-01-XX | Updated COPILOT_CONTEXT_EN.md: Documented resizable splitter and layout refactoring |
Layout Architecture (EnvelopeViewer)
HTML Structure
<div class="pdf-viewer-container">
<div class="pdf-toolbar">
<!-- Zoom, page navigation, thumbnail toggle -->
</div>
<div class="pdf-frame">
@if (_showThumbnails) {
<div class="pdf-thumbnails" style="width: @(_thumbnailWidth)px">
<!-- Page previews -->
</div>
<div class="pdf-splitter" @onmousedown="OnSplitterMouseDown">
<!-- Resizable divider -->
</div>
}
<div class="pdf-canvas-wrapper">
<canvas id="pdf-canvas" class="pdf-canvas"></canvas>
</div>
</div>
</div>
CSS Flexbox Layout
.pdf-frame {
display: flex;
flex-direction: row; /* Side-by-side */
align-items: stretch; /* Same height */
overflow: hidden;
}
.pdf-thumbnails {
flex-shrink: 0; /* Fixed width */
width: 260px; /* Dynamic via inline style */
border-right: none; /* Seamless join with splitter */
}
.pdf-splitter {
width: 4px;
cursor: col-resize;
flex-shrink: 0;
}
.pdf-canvas-wrapper {
flex: 1; /* Fill remaining space */
overflow: auto; /* Scrollable for zoom */
padding: 2rem;
text-align: center;
}
Resizable Splitter Workflow
1. User hovers splitter ? cursor: col-resize (?)
2. Mouse down ? OnSplitterMouseDown(e)
- _isResizing = true
- Store start position (clientX) and width
- Add 'resizing' class to body (prevent text selection)
- Call pdfViewer.startResize()
3. Mouse move (global) ? OnSplitterMouseMove(clientX)
- Calculate delta = clientX - startX
- newWidth = startWidth + delta
- Clamp to 150-400px range
- Update _thumbnailWidth
- StateHasChanged() for reactive UI
4. Mouse up (global) ? OnSplitterMouseUp()
- _isResizing = false
- Remove 'resizing' class
- Save to localStorage("envelopeViewer_thumbnailWidth")
Responsive Behavior
- Desktop (>768px): Flex row, side-by-side
- Mobile (?768px): Flex column, thumbnails on top
- Thumbnail toggle: Controlled by
@if (_showThumbnails)in Razor markup