Add "Copy QR Code" button with clipboard support
Implemented a copy-to-clipboard button for QR codes, including Blazor logic for feedback and disabled states. Injected IJSRuntime for JS interop and added supporting JS functions for image copying and fallback download. Reduced QR code image size for better compatibility. Added responsive and accessible CSS for the new button. Cleaned up timers and feedback state on component disposal.
This commit is contained in:
+74
-3
@@ -2,6 +2,7 @@
|
|||||||
@using QRCoder
|
@using QRCoder
|
||||||
@using SixLabors.ImageSharp.Formats
|
@using SixLabors.ImageSharp.Formats
|
||||||
@using System.ComponentModel.DataAnnotations
|
@using System.ComponentModel.DataAnnotations
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
<PageTitle>QRPay - QR Payment Generator</PageTitle>
|
<PageTitle>QRPay - QR Payment Generator</PageTitle>
|
||||||
|
|
||||||
<div class="qrpay-container">
|
<div class="qrpay-container">
|
||||||
@@ -72,7 +73,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="qrcode-display">
|
<div class="qrcode-display">
|
||||||
<img src="@QRCodeStr" alt="Payment QR Code" />
|
<img id="qrcode-image" src="@QRCodeStr" alt="Payment QR Code" />
|
||||||
|
|
||||||
|
<!-- Copy Button -->
|
||||||
|
<button class="btn-copy @(_isCopying ? "copying" : "")" @onclick="@CopyQRCodeToClipboard" disabled="@_isCopying">
|
||||||
|
@if (_isCopying)
|
||||||
|
{
|
||||||
|
<span class="copy-spinner"></span>
|
||||||
|
<span>Copying...</span>
|
||||||
|
}
|
||||||
|
else if (_copySuccess)
|
||||||
|
{
|
||||||
|
<span>✓ Copied!</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>📋 Copy QR Code</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="payment-details">
|
<div class="payment-details">
|
||||||
@@ -105,6 +123,9 @@
|
|||||||
private string QRCodeStr = string.Empty;
|
private string QRCodeStr = string.Empty;
|
||||||
private bool _showQRCode = false;
|
private bool _showQRCode = false;
|
||||||
private bool _isGenerating = false;
|
private bool _isGenerating = false;
|
||||||
|
private bool _isCopying = false;
|
||||||
|
private bool _copySuccess = false;
|
||||||
|
private System.Threading.Timer? _copySuccessTimer;
|
||||||
|
|
||||||
public class PaymentModel
|
public class PaymentModel
|
||||||
{
|
{
|
||||||
@@ -154,10 +175,11 @@
|
|||||||
var payBySquare = new PayBySquareTextGenerator.PayBySquareOverkill(payment);
|
var payBySquare = new PayBySquareTextGenerator.PayBySquareOverkill(payment);
|
||||||
var stringData = payBySquare.GeneratePayBySquareOverkillString();
|
var stringData = payBySquare.GeneratePayBySquareOverkillString();
|
||||||
|
|
||||||
// Generate QR code data
|
// Generate QR code data - smaller size for better clipboard handling
|
||||||
using var qrCodeData = QRCodeGenerator.GenerateQrCode(stringData, QRCodeGenerator.ECCLevel.Q);
|
using var qrCodeData = QRCodeGenerator.GenerateQrCode(stringData, QRCodeGenerator.ECCLevel.Q);
|
||||||
using var qrCode = new PngByteQRCode(qrCodeData);
|
using var qrCode = new PngByteQRCode(qrCodeData);
|
||||||
byte[] qrCodeImage = qrCode.GetGraphic(20);
|
// Reduced from 20 to 10 for smaller clipboard image
|
||||||
|
byte[] qrCodeImage = qrCode.GetGraphic(10);
|
||||||
|
|
||||||
QRCodeStr = $"data:image/png;base64,{Convert.ToBase64String(qrCodeImage)}";
|
QRCodeStr = $"data:image/png;base64,{Convert.ToBase64String(qrCodeImage)}";
|
||||||
}
|
}
|
||||||
@@ -172,11 +194,60 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task CopyQRCodeToClipboard()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_isCopying = true;
|
||||||
|
_copySuccess = false;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
// Use JavaScript to copy the QR code image to clipboard with smaller size
|
||||||
|
var success = await JSRuntime.InvokeAsync<bool>("copyImageToClipboardSmall", "qrcode-image");
|
||||||
|
|
||||||
|
_isCopying = false;
|
||||||
|
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
_copySuccess = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
// Reset the success state after 3 seconds
|
||||||
|
_copySuccessTimer?.Dispose();
|
||||||
|
_copySuccessTimer = new System.Threading.Timer(_ =>
|
||||||
|
{
|
||||||
|
InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
_copySuccess = false;
|
||||||
|
StateHasChanged();
|
||||||
|
});
|
||||||
|
}, null, TimeSpan.FromSeconds(3), Timeout.InfiniteTimeSpan);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
StateHasChanged();
|
||||||
|
// Could show an error message here if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
_isCopying = false;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void GoBack()
|
public void GoBack()
|
||||||
{
|
{
|
||||||
_showQRCode = false;
|
_showQRCode = false;
|
||||||
QRCodeStr = string.Empty;
|
QRCodeStr = string.Empty;
|
||||||
paymentModel = new PaymentModel(); // Reset the form
|
paymentModel = new PaymentModel(); // Reset the form
|
||||||
|
_copySuccess = false;
|
||||||
|
_copySuccessTimer?.Dispose();
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_copySuccessTimer?.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -174,6 +174,63 @@
|
|||||||
background: rgba(74, 124, 89, 0.2);
|
background: rgba(74, 124, 89, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Copy Button Styles */
|
||||||
|
.btn-copy {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #065f46;
|
||||||
|
border: 2px solid #10b981;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 16px;
|
||||||
|
min-height: 48px;
|
||||||
|
width: auto;
|
||||||
|
max-width: 200px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy:hover:not(:disabled) {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
border-color: #059669;
|
||||||
|
color: #047857;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 2px 6px rgba(16, 185, 129, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy.copying {
|
||||||
|
background: rgba(16, 185, 129, 0.05);
|
||||||
|
border-color: #6ee7b7;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(6, 95, 70, 0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: #065f46;
|
||||||
|
animation: spin 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
@@ -230,6 +287,8 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 16px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Payment Details */
|
/* Payment Details */
|
||||||
@@ -323,6 +382,12 @@
|
|||||||
.form-control {
|
.form-control {
|
||||||
font-size: 16px; /* Prevents zoom on iOS */
|
font-size: 16px; /* Prevents zoom on iOS */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 10px 16px;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Portrait tablets and large phones */
|
/* Portrait tablets and large phones */
|
||||||
@@ -376,6 +441,13 @@
|
|||||||
height: 240px;
|
height: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 10px 14px;
|
||||||
|
max-width: 160px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.payment-details {
|
.payment-details {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
@@ -471,6 +543,13 @@
|
|||||||
height: 200px;
|
height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 8px 12px;
|
||||||
|
max-width: 140px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.payment-details {
|
.payment-details {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
@@ -518,6 +597,12 @@
|
|||||||
height: 180px;
|
height: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 8px 10px;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
.form-control {
|
.form-control {
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
@@ -557,6 +642,12 @@
|
|||||||
height: 160px;
|
height: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 6px 10px;
|
||||||
|
max-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
.payment-details {
|
.payment-details {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
@@ -565,7 +656,8 @@
|
|||||||
/* Touch improvements for mobile */
|
/* Touch improvements for mobile */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.btn-generate:hover,
|
.btn-generate:hover,
|
||||||
.btn-back:hover {
|
.btn-back:hover,
|
||||||
|
.btn-copy:hover {
|
||||||
transform: none; /* Disable hover transforms on mobile */
|
transform: none; /* Disable hover transforms on mobile */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,8 +667,9 @@
|
|||||||
|
|
||||||
/* Larger touch targets */
|
/* Larger touch targets */
|
||||||
.btn-generate,
|
.btn-generate,
|
||||||
.btn-back {
|
.btn-back,
|
||||||
min-height: 48px; /* WCAG recommended minimum touch target */
|
.btn-copy {
|
||||||
|
min-height: 44px; /* WCAG recommended minimum touch target */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,7 +684,8 @@
|
|||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner,
|
||||||
|
.copy-spinner {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,154 @@
|
|||||||
<a href="." class="reload">Reload</a>
|
<a href="." class="reload">Reload</a>
|
||||||
<span class="dismiss">🗙</span>
|
<span class="dismiss">🗙</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- JavaScript functions for clipboard operations -->
|
||||||
|
<script>
|
||||||
|
// Original function for normal copy (kept for compatibility)
|
||||||
|
window.copyImageToClipboard = async function (imageId) {
|
||||||
|
try {
|
||||||
|
// Check if Clipboard API is supported
|
||||||
|
if (!navigator.clipboard || !navigator.clipboard.write) {
|
||||||
|
console.warn('Clipboard API not supported');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = document.getElementById(imageId);
|
||||||
|
if (!img) {
|
||||||
|
console.error('Image element not found');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a canvas to convert the image to blob
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Set canvas dimensions to match image
|
||||||
|
canvas.width = img.naturalWidth || img.width;
|
||||||
|
canvas.height = img.naturalHeight || img.height;
|
||||||
|
|
||||||
|
// Draw the image on canvas
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
// Convert canvas to blob
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
canvas.toBlob(async (blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
console.error('Failed to create blob from canvas');
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create ClipboardItem and write to clipboard
|
||||||
|
const clipboardItem = new ClipboardItem({ 'image/png': blob });
|
||||||
|
await navigator.clipboard.write([clipboardItem]);
|
||||||
|
console.log('Image copied to clipboard successfully');
|
||||||
|
resolve(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy image to clipboard:', error);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
}, 'image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error copying image to clipboard:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// New function for copying smaller images to clipboard
|
||||||
|
window.copyImageToClipboardSmall = async function (imageId) {
|
||||||
|
try {
|
||||||
|
// Check if Clipboard API is supported
|
||||||
|
if (!navigator.clipboard || !navigator.clipboard.write) {
|
||||||
|
console.warn('Clipboard API not supported');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = document.getElementById(imageId);
|
||||||
|
if (!img) {
|
||||||
|
console.error('Image element not found');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a canvas to convert the image to blob
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Set smaller canvas dimensions (max 512x512 for better clipboard handling)
|
||||||
|
const maxSize = 512;
|
||||||
|
let { width, height } = img.naturalWidth && img.naturalHeight ?
|
||||||
|
{ width: img.naturalWidth, height: img.naturalHeight } :
|
||||||
|
{ width: img.width, height: img.height };
|
||||||
|
|
||||||
|
// Scale down if image is too large
|
||||||
|
if (width > maxSize || height > maxSize) {
|
||||||
|
const scale = Math.min(maxSize / width, maxSize / height);
|
||||||
|
width = Math.floor(width * scale);
|
||||||
|
height = Math.floor(height * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
// Draw the image on canvas with scaling
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
// Convert canvas to blob with higher compression
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
canvas.toBlob(async (blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
console.error('Failed to create blob from canvas');
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create ClipboardItem and write to clipboard
|
||||||
|
const clipboardItem = new ClipboardItem({ 'image/png': blob });
|
||||||
|
await navigator.clipboard.write([clipboardItem]);
|
||||||
|
console.log('Smaller image copied to clipboard successfully');
|
||||||
|
resolve(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy image to clipboard:', error);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
}, 'image/png', 0.8); // Lower quality for smaller size
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error copying image to clipboard:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fallback function for browsers that don't support Clipboard API
|
||||||
|
window.downloadQRCode = function (imageId, filename = 'qrcode.png') {
|
||||||
|
try {
|
||||||
|
const img = document.getElementById(imageId);
|
||||||
|
if (!img) {
|
||||||
|
console.error('Image element not found');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary link element
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = img.src;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading image:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
|
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user