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.
This commit is contained in:
2026-09-30 16:12:38 +02:00
parent 640f0b95fe
commit 8386b85592
4 changed files with 276 additions and 13 deletions

View File

@@ -9,15 +9,21 @@
@if (PageCount > 0)
{
<div class="sender-image-editor__toolbar-host">
<SenderPdfImageEditorToolbar CurrentPage="1"
<SenderPdfImageEditorToolbar CurrentPage="@_currentPage"
TotalPages="@PageCount"
ZoomPercent="112" />
ZoomPercent="112"
GoFirstPage="OnGoFirstPageAsync"
GoPreviousPage="OnGoPreviousPageAsync"
GoNextPage="OnGoNextPageAsync"
GoLastPage="OnGoLastPageAsync"
GoToPage="OnGoToPageAsync" />
</div>
}
<div class="pdf-editor-wrapper sender-image-editor sender-editor-pdf-viewer"
style="cursor: @(IsPlacementMode ? "crosshair" : (_isDragging ? "grabbing" : "default"));"
@onclick="HandleClickAsync"
@onscroll="OnEditorScrollAsync"
@onmousemove="OnEditorMouseMoveAsync"
@onmouseup="OnEditorMouseUp"
@onmouseleave="OnEditorMouseUp">
@@ -121,6 +127,7 @@
private bool _isDragging;
private double _grabOffsetXPt;
private double _grabOffsetYPt;
private int _currentPage = 1;
private static string GetFieldStyle(SenderPdfFieldOverlay field, double pageWidthPt, double pageHeightPt)
{
@@ -163,6 +170,17 @@
return $"aspect-ratio:{ratioText};cursor:{cursor};";
}
protected override void OnParametersSet()
{
if (PageCount <= 0)
{
_currentPage = 1;
return;
}
_currentPage = Math.Max(1, Math.Min(_currentPage, PageCount));
}
private async Task HandleClickAsync(MouseEventArgs args)
{
if (EditorClick.HasDelegate)
@@ -219,6 +237,65 @@
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 void OnEditorMouseUp(MouseEventArgs args)
{
_draggedFieldIndex = null;

View File

@@ -1,35 +1,55 @@
<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>
<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>
<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" aria-hidden="true">
<span>@CurrentPageDisplay of @TotalPagesDisplay</span>
<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>
<button type="button" class="sender-image-toolbar__icon-btn sender-image-toolbar__icon-btn--caret" title="Page selector" disabled>
<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>
<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>
<button type="button" class="sender-image-toolbar__icon-btn" title="Next page" disabled>
<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>
<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" />
@@ -91,7 +111,72 @@
[Parameter]
public int ZoomPercent { get; set; } = 112;
[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; }
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 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);
}
}

View File

@@ -145,6 +145,24 @@
height: 18px;
}
.sender-image-toolbar__select-wrap {
position: relative;
width: 1.85rem;
height: 2.1rem;
}
.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: 8.15rem;
height: 2.1rem;
@@ -154,6 +172,7 @@
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 0.35rem;
padding: 0 0.65rem;
color: #233252;
font-size: 0.72rem;
@@ -161,6 +180,30 @@
letter-spacing: 0.01em;
}
.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-editor__pages {
max-width: 980px;
margin: 0 auto;

View File

@@ -127,5 +127,63 @@ window.envelopeEditor = {
}
});
});
},
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;
}
};