Fix lightbox QR download: sync canvas.toDataURL

Async fetch/toBlob lost the user-gesture so Safari/Chrome opened
the PNG instead of downloading. Use a button and sync data-URL save.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sergey Antropoff
2026-07-23 10:34:13 +03:00
parent 271814d4e4
commit 52b14bbc60
2 changed files with 116 additions and 118 deletions
@@ -516,8 +516,10 @@
background: var(--bg-input); background: var(--bg-input);
color: var(--accent); color: var(--accent);
text-decoration: none; text-decoration: none;
font: inherit;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 650; font-weight: 650;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s; transition: color 0.15s, border-color 0.15s, background 0.15s;
} }
.lightbox-download:hover { .lightbox-download:hover {
@@ -759,10 +761,10 @@
</div> </div>
<p class="lightbox-caption" id="lightbox-caption"></p> <p class="lightbox-caption" id="lightbox-caption"></p>
<div class="lightbox-actions"> <div class="lightbox-actions">
<a class="lightbox-download" id="lightbox-download" href="#" download role="button"> <button type="button" class="lightbox-download" id="lightbox-download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Скачать QR Скачать QR
</a> </button>
</div> </div>
</div> </div>
</div> </div>
@@ -827,6 +829,16 @@
var downloadBtn = document.getElementById('lightbox-download'); var downloadBtn = document.getElementById('lightbox-download');
var downloadSrc = ''; var downloadSrc = '';
function triggerDataUrlDownload(dataUrl, filename) {
var a = document.createElement('a');
a.href = dataUrl;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
function triggerFileDownload(blob, filename) { function triggerFileDownload(blob, filename) {
var url = URL.createObjectURL(blob); var url = URL.createObjectURL(blob);
var a = document.createElement('a'); var a = document.createElement('a');
@@ -839,49 +851,36 @@
setTimeout(function () { URL.revokeObjectURL(url); }, 1500); setTimeout(function () { URL.revokeObjectURL(url); }, 1500);
} }
function downloadQrFromCanvas(filename) {
if (!img.complete || !img.naturalWidth) return false;
try {
var canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext('2d').drawImage(img, 0, 0);
// toDataURL sync — download stays in the user-gesture (Safari/Chrome)
triggerDataUrlDownload(canvas.toDataURL('image/png'), filename);
return true;
} catch (err) {
return false;
}
}
function downloadQrImage(e) { function downloadQrImage(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
var filename = downloadBtn.getAttribute('data-filename') || downloadBtn.getAttribute('download') || 'qr.png'; var filename = downloadBtn.getAttribute('data-filename') || 'qr.png';
if (downloadQrFromCanvas(filename)) return;
var src = downloadSrc || img.currentSrc || img.src; var src = downloadSrc || img.currentSrc || img.src;
if (!src) return;
function fromCanvas() { fetch(src)
try { .then(function (res) {
if (!img.complete || !img.naturalWidth) return false; if (!res.ok) throw new Error('fetch failed');
var canvas = document.createElement('canvas'); return res.blob();
canvas.width = img.naturalWidth; })
canvas.height = img.naturalHeight; .then(function (blob) { triggerFileDownload(blob, filename); })
canvas.getContext('2d').drawImage(img, 0, 0); .catch(function () { /* avoid navigating to the image URL */ });
canvas.toBlob(function (blob) {
if (blob) triggerFileDownload(blob, filename);
}, 'image/png');
return true;
} catch (err) {
return false;
}
}
if (src) {
fetch(src)
.then(function (res) {
if (!res.ok) throw new Error('fetch failed');
return res.blob();
})
.then(function (blob) { triggerFileDownload(blob, filename); })
.catch(function () {
if (!fromCanvas()) {
var a = document.createElement('a');
a.href = src;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
});
} else {
fromCanvas();
}
} }
function openLightbox(src, text, alt, filename) { function openLightbox(src, text, alt, filename) {
@@ -890,8 +889,6 @@
img.alt = alt || text || 'QR'; img.alt = alt || text || 'QR';
caption.textContent = text || ''; caption.textContent = text || '';
downloadSrc = src; downloadSrc = src;
downloadBtn.href = '#';
downloadBtn.setAttribute('download', safeName);
downloadBtn.setAttribute('data-filename', safeName); downloadBtn.setAttribute('data-filename', safeName);
downloadBtn.setAttribute('aria-label', 'Скачать ' + safeName); downloadBtn.setAttribute('aria-label', 'Скачать ' + safeName);
box.classList.add('open'); box.classList.add('open');
@@ -938,6 +935,16 @@
})(); })();
(function () { (function () {
function triggerDataUrlDownload(dataUrl, filename) {
var a = document.createElement('a');
a.href = dataUrl;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
function triggerFileDownload(blob, filename) { function triggerFileDownload(blob, filename) {
var url = URL.createObjectURL(blob); var url = URL.createObjectURL(blob);
var a = document.createElement('a'); var a = document.createElement('a');
@@ -953,16 +960,13 @@
function pngFromCard(link, filename) { function pngFromCard(link, filename) {
var card = link.closest('.user-card'); var card = link.closest('.user-card');
var qrImg = card && card.querySelector('img[data-lightbox-src], .qr-block img'); var qrImg = card && card.querySelector('img[data-lightbox-src], .qr-block img');
if (!qrImg) return false; if (!qrImg || !qrImg.complete || !qrImg.naturalWidth) return false;
try { try {
if (!qrImg.complete || !qrImg.naturalWidth) return false;
var canvas = document.createElement('canvas'); var canvas = document.createElement('canvas');
canvas.width = qrImg.naturalWidth; canvas.width = qrImg.naturalWidth;
canvas.height = qrImg.naturalHeight; canvas.height = qrImg.naturalHeight;
canvas.getContext('2d').drawImage(qrImg, 0, 0); canvas.getContext('2d').drawImage(qrImg, 0, 0);
canvas.toBlob(function (blob) { triggerDataUrlDownload(canvas.toDataURL('image/png'), filename);
if (blob) triggerFileDownload(blob, filename);
}, 'image/png');
return true; return true;
} catch (err) { } catch (err) {
return false; return false;
@@ -978,18 +982,13 @@
var fallbackText = link.getAttribute('data-text'); var fallbackText = link.getAttribute('data-text');
var isPng = /\.png$/i.test(filename); var isPng = /\.png$/i.test(filename);
function fallback() { // Sync paths first — async fetch loses the user-gesture in Safari/Chrome
if (fallbackText !== null) { if (isPng && pngFromCard(link, filename)) return;
triggerFileDownload(new Blob([fallbackText], { type: 'text/plain;charset=utf-8' }), filename); if (fallbackText !== null) {
return; triggerFileDownload(new Blob([fallbackText], { type: 'text/plain;charset=utf-8' }), filename);
}
if (isPng) pngFromCard(link, filename);
}
if (!href) {
fallback();
return; return;
} }
if (!href) return;
fetch(href) fetch(href)
.then(function (res) { .then(function (res) {
@@ -997,7 +996,7 @@
return res.blob(); return res.blob();
}) })
.then(function (blob) { triggerFileDownload(blob, filename); }) .then(function (blob) { triggerFileDownload(blob, filename); })
.catch(fallback); .catch(function () { /* do not navigate to the file URL */ });
}); });
}); });
})(); })();
+58 -59
View File
@@ -333,8 +333,10 @@
background: var(--bg-input); background: var(--bg-input);
color: var(--accent); color: var(--accent);
text-decoration: none; text-decoration: none;
font: inherit;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 650; font-weight: 650;
cursor: pointer;
} }
.lightbox-download:hover { .lightbox-download:hover {
border-color: var(--accent); border-color: var(--accent);
@@ -463,10 +465,10 @@
</div> </div>
<p class="lightbox-caption" id="lightbox-caption"></p> <p class="lightbox-caption" id="lightbox-caption"></p>
<div class="lightbox-actions"> <div class="lightbox-actions">
<a class="lightbox-download" id="lightbox-download" href="#" download role="button"> <button type="button" class="lightbox-download" id="lightbox-download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Скачать QR Скачать QR
</a> </button>
</div> </div>
</div> </div>
</div> </div>
@@ -504,6 +506,16 @@
var downloadBtn = document.getElementById('lightbox-download'); var downloadBtn = document.getElementById('lightbox-download');
var downloadSrc = ''; var downloadSrc = '';
function triggerDataUrlDownload(dataUrl, filename) {
var a = document.createElement('a');
a.href = dataUrl;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
function triggerFileDownload(blob, filename) { function triggerFileDownload(blob, filename) {
var url = URL.createObjectURL(blob); var url = URL.createObjectURL(blob);
var a = document.createElement('a'); var a = document.createElement('a');
@@ -516,49 +528,36 @@
setTimeout(function () { URL.revokeObjectURL(url); }, 1500); setTimeout(function () { URL.revokeObjectURL(url); }, 1500);
} }
function downloadQrFromCanvas(filename) {
if (!img.complete || !img.naturalWidth) return false;
try {
var canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext('2d').drawImage(img, 0, 0);
// toDataURL sync — download stays in the user-gesture (Safari/Chrome)
triggerDataUrlDownload(canvas.toDataURL('image/png'), filename);
return true;
} catch (err) {
return false;
}
}
function downloadQrImage(e) { function downloadQrImage(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
var filename = downloadBtn.getAttribute('data-filename') || downloadBtn.getAttribute('download') || 'qr.png'; var filename = downloadBtn.getAttribute('data-filename') || 'qr.png';
if (downloadQrFromCanvas(filename)) return;
var src = downloadSrc || img.currentSrc || img.src; var src = downloadSrc || img.currentSrc || img.src;
if (!src) return;
function fromCanvas() { fetch(src)
try { .then(function (res) {
if (!img.complete || !img.naturalWidth) return false; if (!res.ok) throw new Error('fetch failed');
var canvas = document.createElement('canvas'); return res.blob();
canvas.width = img.naturalWidth; })
canvas.height = img.naturalHeight; .then(function (blob) { triggerFileDownload(blob, filename); })
canvas.getContext('2d').drawImage(img, 0, 0); .catch(function () { /* avoid navigating to the image URL */ });
canvas.toBlob(function (blob) {
if (blob) triggerFileDownload(blob, filename);
}, 'image/png');
return true;
} catch (err) {
return false;
}
}
if (src) {
fetch(src)
.then(function (res) {
if (!res.ok) throw new Error('fetch failed');
return res.blob();
})
.then(function (blob) { triggerFileDownload(blob, filename); })
.catch(function () {
if (!fromCanvas()) {
var a = document.createElement('a');
a.href = src;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
});
} else {
fromCanvas();
}
} }
function openLightbox(src, text, alt, filename) { function openLightbox(src, text, alt, filename) {
@@ -567,8 +566,6 @@
img.alt = alt || text || 'QR'; img.alt = alt || text || 'QR';
caption.textContent = text || ''; caption.textContent = text || '';
downloadSrc = src; downloadSrc = src;
downloadBtn.href = '#';
downloadBtn.setAttribute('download', safeName);
downloadBtn.setAttribute('data-filename', safeName); downloadBtn.setAttribute('data-filename', safeName);
downloadBtn.setAttribute('aria-label', 'Скачать ' + safeName); downloadBtn.setAttribute('aria-label', 'Скачать ' + safeName);
box.classList.add('open'); box.classList.add('open');
@@ -615,6 +612,16 @@
})(); })();
(function () { (function () {
function triggerDataUrlDownload(dataUrl, filename) {
var a = document.createElement('a');
a.href = dataUrl;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
function triggerFileDownload(blob, filename) { function triggerFileDownload(blob, filename) {
var url = URL.createObjectURL(blob); var url = URL.createObjectURL(blob);
var a = document.createElement('a'); var a = document.createElement('a');
@@ -630,16 +637,13 @@
function pngFromCard(link, filename) { function pngFromCard(link, filename) {
var card = link.closest('.user-card'); var card = link.closest('.user-card');
var qrImg = card && card.querySelector('img[data-lightbox-src], .qr-block img'); var qrImg = card && card.querySelector('img[data-lightbox-src], .qr-block img');
if (!qrImg) return false; if (!qrImg || !qrImg.complete || !qrImg.naturalWidth) return false;
try { try {
if (!qrImg.complete || !qrImg.naturalWidth) return false;
var canvas = document.createElement('canvas'); var canvas = document.createElement('canvas');
canvas.width = qrImg.naturalWidth; canvas.width = qrImg.naturalWidth;
canvas.height = qrImg.naturalHeight; canvas.height = qrImg.naturalHeight;
canvas.getContext('2d').drawImage(qrImg, 0, 0); canvas.getContext('2d').drawImage(qrImg, 0, 0);
canvas.toBlob(function (blob) { triggerDataUrlDownload(canvas.toDataURL('image/png'), filename);
if (blob) triggerFileDownload(blob, filename);
}, 'image/png');
return true; return true;
} catch (err) { } catch (err) {
return false; return false;
@@ -655,18 +659,13 @@
var fallbackText = link.getAttribute('data-text'); var fallbackText = link.getAttribute('data-text');
var isPng = /\.png$/i.test(filename); var isPng = /\.png$/i.test(filename);
function fallback() { // Sync paths first — async fetch loses the user-gesture in Safari/Chrome
if (fallbackText !== null) { if (isPng && pngFromCard(link, filename)) return;
triggerFileDownload(new Blob([fallbackText], { type: 'text/plain;charset=utf-8' }), filename); if (fallbackText !== null) {
return; triggerFileDownload(new Blob([fallbackText], { type: 'text/plain;charset=utf-8' }), filename);
}
if (isPng) pngFromCard(link, filename);
}
if (!href) {
fallback();
return; return;
} }
if (!href) return;
fetch(href) fetch(href)
.then(function (res) { .then(function (res) {
@@ -674,7 +673,7 @@
return res.blob(); return res.blob();
}) })
.then(function (blob) { triggerFileDownload(blob, filename); }) .then(function (blob) { triggerFileDownload(blob, filename); })
.catch(fallback); .catch(function () { /* do not navigate to the file URL */ });
}); });
}); });
})(); })();