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.
435 lines
18 KiB
Markdown
435 lines
18 KiB
Markdown
# 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 `DotNetObjectReference` for 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`):**
|
||
```javascript
|
||
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 with `col-resize` cursor
|
||
- `.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
|
||
|
||
1. **Unlimited Zoom:**
|
||
- Canvas size not restricted by `max-width`
|
||
- Frame stays fixed, scroll bars appear automatically
|
||
- `text-align: center` for small sizes, full scroll for zoomed views
|
||
|
||
2. **Global Mouse Wheel Zoom:**
|
||
- Event listener on `document.body` (works anywhere on page)
|
||
- `Ctrl + Mouse Wheel` triggers `zoomIn()`/`zoomOut()`
|
||
- Calls `dotNetReference.invokeMethodAsync('OnZoomChanged', scale)` to update Blazor UI
|
||
- `{ passive: false }` to enable `preventDefault()`
|
||
|
||
3. **Render Task Cancellation:**
|
||
- Stores `currentRenderTask` to cancel previous render if new one starts
|
||
- Catches `RenderingCancelledException` to avoid console errors
|
||
- Queue system (`pageNumPending`) for rapid page changes
|
||
|
||
4. **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
|
||
|
||
5. **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-resize` cursor (?) for intuitive UX
|
||
|
||
6. **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)
|
||
|
||
7. **Responsive Design:**
|
||
- Desktop: 90% width, 1200px max
|
||
- Mobile: 95% width, adjusted heights, thumbnails collapse to top
|
||
- Adaptive padding and font sizes
|
||
|
||
### Flow
|
||
|
||
1. **Component Load:**
|
||
```csharp
|
||
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)
|
||
```
|
||
|
||
2. **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
|
||
|
||
3. **Cleanup:**
|
||
```csharp
|
||
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`)
|
||
1. `AuthService.CheckEnvelopeAccessAsync` ? redirect to login if unauthorized
|
||
2. `AnnotationService.GetAnnotationsAsync` ? fills `_annotations`
|
||
3. `DocumentService.GetDocumentAsync` ? fills `_basePdfBytes` (real mode)
|
||
4. `BuildFreshBaseReport()` ? `XtraReport` for `DxReportViewer`
|
||
|
||
### Signature Popup
|
||
- Tabs: Draw / Text / Image
|
||
- Fields: full name (required), position (optional), place (required)
|
||
- Saved to `_capturedSignature` record
|
||
- If annotations exist ? popup closes ? JS checkbox overlays installed
|
||
|
||
### JS Checkbox Overlay (`receiver-signature.js`)
|
||
- `receiverSignature.installAnnotationCheckboxes(annotations, checkedIds, dotNetRef)`
|
||
- One `.annot-sig-cb-wrapper` div per annotation, absolutely positioned over viewer scroll container
|
||
- Position: `left = pageRect.left + ann.x * scaleX`, `top = pageRect.top + ann.y * scaleY`
|
||
- `scaleX = pagePixelWidth / 827`, `scaleY = pagePixelHeight / 1169`
|
||
- Click ? `dotNetRef.invokeMethodAsync('OnAnnotationToggled', id, checked)`
|
||
|
||
### Apply Signatures ("Unterschriften anwenden" — `SubmitSignaturesAsync`)
|
||
|
||
**Real PDF mode (`_basePdfBytes` is set):**
|
||
- Calls `StampSignaturesOnPdf` using **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 `XtraReport` with `XRPdfContent`
|
||
- 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
|
||
|
||
```csharp
|
||
// 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()
|
||
|
||
```csharp
|
||
// 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
|
||
```html
|
||
<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
|
||
```css
|
||
.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
|
||
|
||
|