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.
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Client.Components.PdfEditor;
|
||||||
|
|
||||||
|
public sealed record SenderPdfFieldMoveRequest(
|
||||||
|
int Index,
|
||||||
|
int Page,
|
||||||
|
double XPt,
|
||||||
|
double YPt);
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
@using EnvelopeGenerator.Application.Common.Dto
|
@using EnvelopeGenerator.Application.Common.Dto
|
||||||
@using Microsoft.Extensions.Localization
|
@using Microsoft.Extensions.Localization
|
||||||
@inject IStringLocalizer<Resource> UiLocalizer
|
@inject IStringLocalizer<Resource> UiLocalizer
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
|
|
||||||
<div class="pdf-editor-wrapper sender-image-editor sender-editor-pdf-viewer"
|
<div class="pdf-editor-wrapper sender-image-editor sender-editor-pdf-viewer"
|
||||||
style="cursor: @(IsPlacementMode ? "crosshair" : "default");"
|
style="cursor: @(IsPlacementMode ? "crosshair" : "default");"
|
||||||
@@ -33,7 +34,13 @@
|
|||||||
data-editor-page="@pageNumber"
|
data-editor-page="@pageNumber"
|
||||||
data-page-width-pt="@widthPt"
|
data-page-width-pt="@widthPt"
|
||||||
data-page-height-pt="@heightPt">
|
data-page-height-pt="@heightPt">
|
||||||
<div class="sender-image-editor__page-canvas">
|
<div class="sender-image-editor__page-canvas"
|
||||||
|
data-editor-page-canvas="@pageNumber"
|
||||||
|
ondragover="event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';"
|
||||||
|
ondragenter="event.preventDefault();"
|
||||||
|
@ondrop:preventDefault="true"
|
||||||
|
@ondrop:stopPropagation="true"
|
||||||
|
@ondrop="args => OnPageDropAsync(args, pageNumber, widthPt, heightPt)">
|
||||||
@if (!string.IsNullOrWhiteSpace(pagePreview?.ImageDataUrl))
|
@if (!string.IsNullOrWhiteSpace(pagePreview?.ImageDataUrl))
|
||||||
{
|
{
|
||||||
<img class="sender-image-editor__page-image"
|
<img class="sender-image-editor__page-image"
|
||||||
@@ -48,14 +55,25 @@
|
|||||||
|
|
||||||
@foreach (var field in Fields.Where(f => f.Page == pageNumber))
|
@foreach (var field in Fields.Where(f => f.Page == pageNumber))
|
||||||
{
|
{
|
||||||
<button type="button"
|
<div class="sender-image-editor__field"
|
||||||
class="sender-image-editor__field"
|
style="@GetFieldStyle(field, widthPt, heightPt)"
|
||||||
style="@GetFieldStyle(field, widthPt, heightPt)"
|
title="@field.ReceiverName"
|
||||||
title="@field.ReceiverName"
|
draggable="true"
|
||||||
@onclick:stopPropagation="true"
|
@onmousedown="() => OnFieldDragStart(field.Index)"
|
||||||
@onclick="() => RemoveField.InvokeAsync(field.Index)">
|
ondragstart="event.dataTransfer.setData('text/plain', 'field'); event.dataTransfer.effectAllowed = 'move';"
|
||||||
@field.ReceiverName
|
@onclick:stopPropagation="true">
|
||||||
</button>
|
<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>
|
||||||
@@ -86,6 +104,11 @@
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public EventCallback<int> RemoveField { get; set; }
|
public EventCallback<int> RemoveField { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<SenderPdfFieldMoveRequest> MoveField { get; set; }
|
||||||
|
|
||||||
|
private int? _draggedFieldIndex;
|
||||||
|
|
||||||
private static string GetFieldStyle(SenderPdfFieldOverlay field, double pageWidthPt, double pageHeightPt)
|
private static string GetFieldStyle(SenderPdfFieldOverlay field, double pageWidthPt, double pageHeightPt)
|
||||||
{
|
{
|
||||||
var leftPercent = (field.XPt / pageWidthPt) * 100.0;
|
var leftPercent = (field.XPt / pageWidthPt) * 100.0;
|
||||||
@@ -108,6 +131,40 @@
|
|||||||
await EditorClick.InvokeAsync(args);
|
await EditorClick.InvokeAsync(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnFieldDragStart(int fieldIndex)
|
||||||
|
{
|
||||||
|
_draggedFieldIndex = fieldIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnPageDropAsync(DragEventArgs args, int pageNumber, double pageWidthPt, double pageHeightPt)
|
||||||
|
{
|
||||||
|
if (_draggedFieldIndex is null || !MoveField.HasDelegate)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var coords = await JSRuntime.InvokeAsync<DropCoords?>(
|
||||||
|
"envelopeEditor.getDropCoordsOnPage",
|
||||||
|
"sender-editor-pdf-viewer",
|
||||||
|
pageNumber,
|
||||||
|
args.ClientX,
|
||||||
|
args.ClientY);
|
||||||
|
|
||||||
|
if (coords is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const double sigWidthPt = 1.77 * 72;
|
||||||
|
const double sigHeightPt = 1.96 * 72;
|
||||||
|
|
||||||
|
var xPt = coords.NormX * pageWidthPt;
|
||||||
|
var yPt = coords.NormY * pageHeightPt;
|
||||||
|
xPt = Math.Max(0, Math.Min(xPt, pageWidthPt - sigWidthPt));
|
||||||
|
yPt = Math.Max(0, Math.Min(yPt, pageHeightPt - sigHeightPt));
|
||||||
|
|
||||||
|
await MoveField.InvokeAsync(new SenderPdfFieldMoveRequest(_draggedFieldIndex.Value, pageNumber, xPt, yPt));
|
||||||
|
_draggedFieldIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record DropCoords(double NormX, double NormY);
|
||||||
|
|
||||||
private string L(string key, string fallback)
|
private string L(string key, string fallback)
|
||||||
{
|
{
|
||||||
var value = UiLocalizer[key];
|
var value = UiLocalizer[key];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
@using EnvelopeGenerator.Application.Common.Dto
|
@using EnvelopeGenerator.Application.Common.Dto
|
||||||
@using EnvelopeGenerator.Application.Envelopes.Commands
|
@using EnvelopeGenerator.Application.Envelopes.Commands
|
||||||
@using EnvelopeGenerator.Application.Resources
|
@using EnvelopeGenerator.Application.Resources
|
||||||
|
@using EnvelopeGenerator.Server.Client.Components.PdfEditor
|
||||||
@using EnvelopeGenerator.Server.Client.Services
|
@using EnvelopeGenerator.Server.Client.Services
|
||||||
@using Microsoft.AspNetCore.WebUtilities
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
@@ -336,6 +337,7 @@
|
|||||||
PdfDataUrl="@PdfDataUrl"
|
PdfDataUrl="@PdfDataUrl"
|
||||||
Fields="@FieldOverlays"
|
Fields="@FieldOverlays"
|
||||||
EditorClick="OnPdfAreaClickAsync"
|
EditorClick="OnPdfAreaClickAsync"
|
||||||
|
MoveField="OnMoveFieldAsync"
|
||||||
RemoveField="OnRemoveFieldByIndexAsync" />
|
RemoveField="OnRemoveFieldByIndexAsync" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -806,6 +808,22 @@
|
|||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async Task OnMoveFieldAsync(SenderPdfFieldMoveRequest request)
|
||||||
|
{
|
||||||
|
if (request.Index < 0 || request.Index >= _signatureFields.Count)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var field = _signatureFields[request.Index];
|
||||||
|
_signatureFields[request.Index] = field with
|
||||||
|
{
|
||||||
|
XPt = request.XPt,
|
||||||
|
YPt = request.YPt,
|
||||||
|
Page = request.Page
|
||||||
|
};
|
||||||
|
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Clear all fields ──
|
// ── Clear all fields ──
|
||||||
async Task ClearAllFieldsAsync()
|
async Task ClearAllFieldsAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -134,13 +134,47 @@
|
|||||||
padding: 0.2rem 0.3rem;
|
padding: 0.2rem 0.3rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
cursor: pointer;
|
cursor: grab;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: flex-start;
|
justify-content: space-between;
|
||||||
|
gap: 0.35rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sender-image-editor__field:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-image-editor__field-label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-image-editor__field-remove {
|
||||||
|
border: none;
|
||||||
|
background: rgba(15, 23, 42, 0.78);
|
||||||
|
color: #ffffff;
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
min-width: 1.1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-image-editor__field-remove:hover {
|
||||||
|
background: rgba(220, 38, 38, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
.sender-image-editor__field:hover {
|
.sender-image-editor__field:hover {
|
||||||
filter: brightness(0.96);
|
filter: brightness(0.96);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,5 +92,31 @@ window.envelopeEditor = {
|
|||||||
normY: normY,
|
normY: normY,
|
||||||
pageIndex: targetIndex
|
pageIndex: targetIndex
|
||||||
};
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getDropCoordsOnPage: function (viewerCssClass, pageNumber, clientX, clientY) {
|
||||||
|
const viewer = document.querySelector('.' + viewerCssClass);
|
||||||
|
if (!viewer) {
|
||||||
|
console.warn('[envelopeEditor] viewer not found for class:', viewerCssClass);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = viewer.querySelector('[data-editor-page="' + pageNumber + '"]');
|
||||||
|
if (!page) {
|
||||||
|
console.warn('[envelopeEditor] page not found:', pageNumber);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = page.getBoundingClientRect();
|
||||||
|
const clampedX = Math.max(rect.left, Math.min(clientX, rect.right));
|
||||||
|
const clampedY = Math.max(rect.top, Math.min(clientY, rect.bottom));
|
||||||
|
|
||||||
|
const normX = (clampedX - rect.left) / rect.width;
|
||||||
|
const normY = (clampedY - rect.top) / rect.height;
|
||||||
|
|
||||||
|
return {
|
||||||
|
normX: normX,
|
||||||
|
normY: normY
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user