Refactor all templates to dictpress v5 (Rust/Tera templates).
This commit is contained in:
+194
-351
@@ -1,364 +1,207 @@
|
||||
async function screenshotDOM(el, opts = {}) {
|
||||
const {
|
||||
scale = 2,
|
||||
type = 'image/png',
|
||||
quality = 0.92,
|
||||
background = '#fff',
|
||||
// if you want to *allow* splitting (and risk the bug), set to false
|
||||
preventCapsuleSplit = true
|
||||
} = opts;
|
||||
// Canvas-based entry card screenshot and share.
|
||||
|
||||
if (!(el instanceof Element)) throw new TypeError('Expected a DOM Element');
|
||||
function renderEntryCard(entryEl) {
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const colors = {
|
||||
bg: '#fff',
|
||||
primary: cs.getPropertyValue('--primary').trim() || '#111',
|
||||
light: cs.getPropertyValue('--light').trim() || '#666',
|
||||
lighter: cs.getPropertyValue('--lighter').trim() || '#aaa',
|
||||
border: '#e6e6e6',
|
||||
};
|
||||
const fontFamily = getComputedStyle(document.body).fontFamily;
|
||||
const font = (size, bold) => `${bold ? 'bold ' : ''}${size}px ${fontFamily}`;
|
||||
|
||||
if (document.fonts && document.fonts.status !== 'loaded') {
|
||||
try { await document.fonts.ready; } catch { }
|
||||
// Extract data from entry DOM.
|
||||
const headword = entryEl.dataset.head || '';
|
||||
const pronun = entryEl.querySelector('.pronun')?.textContent?.trim() || '';
|
||||
|
||||
// Collect definition groups: [{type, defs}]
|
||||
const groups = [];
|
||||
entryEl.querySelectorAll('ol.defs').forEach((ol) => {
|
||||
let typeLabel = '';
|
||||
const defs = [];
|
||||
ol.querySelectorAll(':scope > li').forEach((li) => {
|
||||
if (li.classList.contains('types')) {
|
||||
typeLabel = li.textContent.trim();
|
||||
} else {
|
||||
const defEl = li.querySelector('.def');
|
||||
if (defEl) {
|
||||
let text = '';
|
||||
for (const node of defEl.childNodes) {
|
||||
if (node.matches?.('.more, .more-toggle, .edit')) break;
|
||||
text += node.textContent;
|
||||
}
|
||||
text = text.trim().replace(/\s+/g, ' ');
|
||||
if (text) defs.push(text);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (defs.length > 0) {
|
||||
groups.push({ type: typeLabel, defs });
|
||||
}
|
||||
});
|
||||
|
||||
// Layout constants.
|
||||
const W = 600, pad = 32, contentW = W - pad * 2;
|
||||
const headSize = 22, pronunSize = 14, typeSize = 13, defSize = 15;
|
||||
const lineHeight = 1.45;
|
||||
const scale = Math.max(2, window.devicePixelRatio || 2);
|
||||
const numIndent = 24;
|
||||
|
||||
// Text wrapping.
|
||||
const mctx = document.createElement('canvas').getContext('2d');
|
||||
const defFont = font(defSize);
|
||||
const defMaxW = contentW - numIndent;
|
||||
|
||||
function wrapText(text) {
|
||||
mctx.font = defFont;
|
||||
const words = text.split(' ');
|
||||
const lines = [];
|
||||
let line = '';
|
||||
for (const word of words) {
|
||||
const test = line ? line + ' ' + word : word;
|
||||
if (mctx.measureText(test).width > defMaxW && line) {
|
||||
lines.push(line);
|
||||
line = word;
|
||||
} else {
|
||||
line = test;
|
||||
}
|
||||
}
|
||||
if (line) lines.push(line);
|
||||
return lines.length ? lines : [''];
|
||||
}
|
||||
|
||||
// Pre-wrap all definitions.
|
||||
const groupLayouts = groups.map((g) => ({
|
||||
type: g.type,
|
||||
defs: g.defs.map(wrapText),
|
||||
}));
|
||||
|
||||
// Unified layout: measures when ctx is null, draws when provided.
|
||||
function doLayout(ctx) {
|
||||
let y = pad;
|
||||
|
||||
if (ctx) {
|
||||
ctx.font = font(headSize, true);
|
||||
ctx.fillStyle = colors.primary;
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(headword, pad, y);
|
||||
}
|
||||
y += headSize * lineHeight;
|
||||
|
||||
if (pronun) {
|
||||
if (ctx) {
|
||||
ctx.font = font(pronunSize);
|
||||
ctx.fillStyle = colors.light;
|
||||
ctx.fillText(pronun, pad, y);
|
||||
}
|
||||
y += pronunSize * lineHeight + 2;
|
||||
}
|
||||
y += 12;
|
||||
|
||||
for (const gl of groupLayouts) {
|
||||
if (gl.type) {
|
||||
if (ctx) {
|
||||
ctx.font = font(typeSize, true);
|
||||
ctx.fillStyle = colors.light;
|
||||
const tw = ctx.measureText(gl.type).width;
|
||||
ctx.fillText(gl.type, pad, y);
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.strokeStyle = colors.lighter;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad, y + typeSize + 2);
|
||||
ctx.lineTo(pad + tw, y + typeSize + 2);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
y += typeSize * lineHeight + 8;
|
||||
}
|
||||
|
||||
for (let i = 0; i < gl.defs.length; i++) {
|
||||
if (ctx) {
|
||||
ctx.font = defFont;
|
||||
ctx.fillStyle = colors.lighter;
|
||||
ctx.fillText(`${i + 1}.`, pad, y);
|
||||
ctx.fillStyle = colors.primary;
|
||||
}
|
||||
for (const line of gl.defs[i]) {
|
||||
if (ctx) ctx.fillText(line, pad + numIndent, y);
|
||||
y += defSize * lineHeight;
|
||||
}
|
||||
y += 4;
|
||||
}
|
||||
y += 4;
|
||||
}
|
||||
|
||||
const { width, height } = (() => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { width: Math.ceil(r.width), height: Math.ceil(r.height) };
|
||||
})();
|
||||
if (!width || !height) throw new Error('Element has zero width/height.');
|
||||
return y + pad - 4;
|
||||
}
|
||||
|
||||
const clone = el.cloneNode(true);
|
||||
await inlineEverything(el, clone);
|
||||
// Measure, create canvas, draw.
|
||||
const H = Math.ceil(doLayout(null));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = W * scale;
|
||||
canvas.height = H * scale;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
if (background !== 'transparent') clone.style.background = background;
|
||||
if (!clone.getAttribute('xmlns')) clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
|
||||
const word = el.querySelector("h3").textContent.trim();
|
||||
const phonetic = el.querySelector(".pronun").textContent.trim();
|
||||
const [types, ...defs] = [...el.querySelectorAll("ol.defs li")].map(node => node.textContent.trim());
|
||||
// Card background.
|
||||
ctx.fillStyle = colors.bg;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(4, 4, W - 8, H - 8, 8);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = colors.border;
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.stroke();
|
||||
|
||||
function charsPerLine(text, maxWidth) {
|
||||
const fontSize = Math.round(width * 0.025);
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.font = `${fontSize}px Helvetica Neue, Segoe UI, Helvetica, sans-serif`;
|
||||
doLayout(ctx);
|
||||
|
||||
return Math.floor(maxWidth / (ctx.measureText(text).width / text.length));
|
||||
}
|
||||
|
||||
const renderDefs = (defs, x, y) => {
|
||||
let yOffset = y;
|
||||
const offset = 20;
|
||||
const maxLength = charsPerLine(defs[0], width - 60);
|
||||
|
||||
return defs.map((def, idx) => {
|
||||
if (idx > 0) {
|
||||
yOffset += vOffset;
|
||||
}
|
||||
|
||||
let part = '';
|
||||
const parts = [];
|
||||
const words = def.split(' ');
|
||||
|
||||
for (const word of words) {
|
||||
if ((part + ' ' + word).trim().length <= maxLength) {
|
||||
part = (part + ' ' + word).trim();
|
||||
} else {
|
||||
if (part) parts.push(part);
|
||||
part = word;
|
||||
}
|
||||
}
|
||||
|
||||
if (part) parts.push(part);
|
||||
|
||||
return `
|
||||
<text class="num" x="0" y="${yOffset}">${idx + 1}.</text>
|
||||
<text class="def" x="${x}" y="${yOffset}">
|
||||
${parts.map((part, idx) => {
|
||||
if (idx > 0) {
|
||||
yOffset += offset;
|
||||
}
|
||||
return `<tspan x="${x}" y="${yOffset}">${part}</tspan>`
|
||||
}).join('')}
|
||||
</text>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const vOffset = 25;
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
<style>
|
||||
.card { fill: #ffffff; stroke: #e5e7eb; stroke-width: 1.2; rx: 5; ry: 5; }
|
||||
.headword {
|
||||
font-size: ${Math.round(width * 0.03)}px;
|
||||
font-weight: 700;
|
||||
fill: #111827;
|
||||
}
|
||||
.pron {
|
||||
font-size: ${Math.round(width * 0.025)}px;
|
||||
fill: #6b7280;
|
||||
}
|
||||
.pos {
|
||||
font-size: ${Math.round(width * 0.017)}px;
|
||||
font-weight: bold;
|
||||
fill: #666;
|
||||
}
|
||||
.def {
|
||||
font-size: ${Math.round(width * 0.025)}px;
|
||||
fill: #111827;
|
||||
}
|
||||
.num {
|
||||
font-size: ${Math.round(width * 0.025)}px;
|
||||
fill: #6b7280;
|
||||
}
|
||||
|
||||
text {
|
||||
font-family: "Helvetica Neue", "Segoe UI", Helvetica, sans-serif;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Card background -->
|
||||
<rect x="8" y="8" width="${width - 16}" height="${height - 16}" rx="5" ry="5" class="card"/>
|
||||
|
||||
<!-- Content -->
|
||||
<g transform="translate(32,36)">
|
||||
<!-- Headword -->
|
||||
<text class="headword" x="0" y="0">${word}</text>
|
||||
|
||||
<!-- Pronunciation -->
|
||||
<text class="pron" x="0" y="20">${phonetic}</text>
|
||||
|
||||
<!-- POS -->
|
||||
<text class="pos" x="13" y="45">${types}</text>
|
||||
|
||||
<!-- Definition 1 -->
|
||||
${renderDefs(defs, 24, 75)}
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const svgBlob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
|
||||
try {
|
||||
const img = await loadImage(url);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.ceil(width * scale);
|
||||
canvas.height = Math.ceil(height * scale);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (background !== 'transparent' || type !== 'image/png') {
|
||||
ctx.fillStyle = background === 'transparent' ? '#0000' : background;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
const blob = await new Promise(res => canvas.toBlob(res, type, quality));
|
||||
if (!blob) throw new Error('Canvas.toBlob returned null.');
|
||||
return blob;
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function loadImage(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.decoding = 'async';
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
async function inlineEverything(srcNode, dstNode) {
|
||||
copyComputedStyle(srcNode, dstNode);
|
||||
|
||||
// Remove interactive states that may be captured on touch devices
|
||||
if (dstNode instanceof Element) {
|
||||
// Reset outline from :focus state
|
||||
dstNode.style.setProperty('outline', 'none', 'important');
|
||||
// Reset any pointer-events to ensure no hover states
|
||||
const cs = window.getComputedStyle(srcNode);
|
||||
// Only override cursor if it's pointer (indicating interactivity)
|
||||
if (cs.cursor === 'pointer') {
|
||||
dstNode.style.setProperty('cursor', 'default', 'important');
|
||||
}
|
||||
}
|
||||
|
||||
if (srcNode instanceof HTMLTextAreaElement) {
|
||||
dstNode.textContent = srcNode.value;
|
||||
} else if (srcNode instanceof HTMLInputElement) {
|
||||
dstNode.setAttribute('value', srcNode.value);
|
||||
if ((srcNode.type === 'checkbox' || srcNode.type === 'radio') && srcNode.checked) {
|
||||
dstNode.setAttribute('checked', '');
|
||||
}
|
||||
} else if (srcNode instanceof HTMLSelectElement) {
|
||||
const sel = Array.from(srcNode.options).filter(o => o.selected).map(o => o.value);
|
||||
Array.from(dstNode.options).forEach(o => (o.selected = sel.includes(o.value)));
|
||||
}
|
||||
|
||||
if (srcNode instanceof HTMLCanvasElement) {
|
||||
try {
|
||||
const dataURL = srcNode.toDataURL();
|
||||
const img = document.createElement('img');
|
||||
img.src = dataURL;
|
||||
copyBoxSizing(dstNode, img);
|
||||
dstNode.replaceWith(img);
|
||||
dstNode = img;
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// Handle img elements to ensure they render properly
|
||||
if (srcNode instanceof HTMLImageElement && dstNode instanceof HTMLImageElement) {
|
||||
// Convert image to data URL for inlining
|
||||
if (srcNode.complete && srcNode.naturalWidth > 0) {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = srcNode.naturalWidth;
|
||||
canvas.height = srcNode.naturalHeight;
|
||||
ctx.drawImage(srcNode, 0, 0);
|
||||
dstNode.src = canvas.toDataURL('image/png');
|
||||
// Also set width/height to ensure proper rendering
|
||||
const cs = window.getComputedStyle(srcNode);
|
||||
dstNode.style.width = cs.width;
|
||||
dstNode.style.height = cs.height;
|
||||
dstNode.style.objectFit = cs.objectFit;
|
||||
} catch (err) {
|
||||
// If cross-origin or other error, keep original src
|
||||
console.log('Could not inline image:', err);
|
||||
dstNode.src = srcNode.src;
|
||||
}
|
||||
} else {
|
||||
// Image not loaded or has no dimensions, use src as-is
|
||||
dstNode.src = srcNode.src || srcNode.getAttribute('src') || '';
|
||||
}
|
||||
// Remove alt text from being rendered
|
||||
dstNode.removeAttribute('alt');
|
||||
}
|
||||
|
||||
materializePseudo(srcNode, dstNode, '::before');
|
||||
materializePseudo(srcNode, dstNode, '::after');
|
||||
|
||||
const sKids = srcNode.childNodes;
|
||||
const dKids = dstNode.childNodes;
|
||||
for (let i = 0; i < sKids.length; i++) {
|
||||
const s = sKids[i], d = dKids[i];
|
||||
if (s && d && s.nodeType === 1 && d.nodeType === 1) await inlineEverything(s, d);
|
||||
}
|
||||
|
||||
function copyComputedStyle(src, dst) {
|
||||
const cs = window.getComputedStyle(src);
|
||||
let cssText = '';
|
||||
for (const prop of cs) cssText += `${prop}:${cs.getPropertyValue(prop)};`;
|
||||
dst.setAttribute('style', (dst.getAttribute('style') || '') + cssText);
|
||||
|
||||
// Always keep transform origin consistent
|
||||
if (cs.transformOrigin) dst.style.transformOrigin = cs.transformOrigin;
|
||||
|
||||
// Keep each line painting its own decoration (helps some engines)
|
||||
dst.style.setProperty('box-decoration-break', 'clone', 'important');
|
||||
dst.style.setProperty('-webkit-box-decoration-break', 'clone', 'important');
|
||||
|
||||
// --- CAPSULE FIX: prevent decorated inline from splitting across lines ---
|
||||
if (preventCapsuleSplit) {
|
||||
const hasBg = (cs.backgroundImage && cs.backgroundImage !== 'none') ||
|
||||
(cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent');
|
||||
const hasRadius = ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomRightRadius', 'borderBottomLeftRadius']
|
||||
.some(k => parseFloat(cs[k]) > 0);
|
||||
const hasPad = ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft']
|
||||
.some(k => parseFloat(cs[k]) > 0);
|
||||
const isInlineLevel = cs.display.startsWith('inline');
|
||||
|
||||
if (isInlineLevel && (hasBg || hasRadius || hasPad)) {
|
||||
// Make it behave like a chip for capture: no internal wrapping
|
||||
dst.style.setProperty('display', 'inline-block', 'important');
|
||||
dst.style.setProperty('white-space', 'nowrap', 'important');
|
||||
// In case original allowed breaking long words, emulate by clipping
|
||||
if (cs.overflowWrap === 'break-word' || cs.wordBreak === 'break-all') {
|
||||
dst.style.setProperty('max-width', cs.maxWidth && cs.maxWidth !== 'none' ? cs.maxWidth : '100%', 'important');
|
||||
dst.style.setProperty('overflow', 'hidden', 'important');
|
||||
dst.style.setProperty('text-overflow', 'ellipsis', 'important');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyBoxSizing(src, dst) {
|
||||
const cs = window.getComputedStyle(src);
|
||||
dst.style.width = cs.width;
|
||||
dst.style.height = cs.height;
|
||||
dst.style.display = cs.display;
|
||||
dst.style.objectFit = 'contain';
|
||||
}
|
||||
|
||||
function materializePseudo(src, dst, which) {
|
||||
const ps = window.getComputedStyle(src, which);
|
||||
if (!ps || ps.content === '' || ps.content === 'none') return;
|
||||
const span = document.createElement('span');
|
||||
let cssText = '';
|
||||
for (const prop of ps) cssText += `${prop}:${ps.getPropertyValue(prop)};`;
|
||||
span.setAttribute('style', cssText);
|
||||
|
||||
const content = ps.content;
|
||||
const quoted = /^(['"]).*\1$/.test(content);
|
||||
if (quoted) span.textContent = content.slice(1, -1).replace(/\\n/g, '\n');
|
||||
else if (content.startsWith('attr(')) {
|
||||
const attrName = content.slice(5, -1).trim();
|
||||
span.textContent = src.getAttribute(attrName) || '';
|
||||
} else span.textContent = '';
|
||||
|
||||
if (which === '::before') dst.insertBefore(span, dst.firstChild);
|
||||
else dst.appendChild(span);
|
||||
}
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob((blob) => resolve(blob), 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
async function shareDOM(target, title, text, filename) {
|
||||
async function shareEntry(entryEl) {
|
||||
const blob = await renderEntryCard(entryEl);
|
||||
const head = entryEl.dataset.head || 'entry';
|
||||
const filename = `${head.replace(/[^a-zA-Z0-9\u0900-\u097F\u0D00-\u0D7F]/g, '_')}.png`;
|
||||
const file = new File([blob], filename, { type: 'image/png', lastModified: Date.now() });
|
||||
|
||||
// 1. Try Web Share with image file.
|
||||
if (navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||
await navigator.share({ files: [file] });
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Try Web Share with text/URL (no file support).
|
||||
if (navigator.share) {
|
||||
const def = entryEl.querySelector('.def');
|
||||
const url = `${window.location.origin}${window.location.pathname}#${entryEl.id}`;
|
||||
await navigator.share({
|
||||
title: head,
|
||||
text: def?.textContent?.trim() || '',
|
||||
url: url,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Try clipboard.
|
||||
if (navigator.clipboard && window.ClipboardItem) {
|
||||
try {
|
||||
// Remove any active/focus states before capturing
|
||||
// This is especially important on touch devices (Android)
|
||||
if (document.activeElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
|
||||
alert('Screenshot copied to clipboard');
|
||||
return;
|
||||
} catch (e) {
|
||||
console.log('Clipboard write failed, downloading instead:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait a brief moment for any :active states to clear
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Make a crisp image (PNG keeps transparency; use JPEG for smaller files)
|
||||
const blob = await screenshotDOM(target, {
|
||||
scale: Math.max(2, window.devicePixelRatio || 2),
|
||||
type: 'image/png',
|
||||
background: 'white'
|
||||
});
|
||||
|
||||
const file = new File([blob], filename || 'share.png', {
|
||||
type: blob.type,
|
||||
lastModified: Date.now()
|
||||
});
|
||||
|
||||
// Web Share API with files (Android Chrome, iOS/iPadOS Safari 16+).
|
||||
if (navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||
await navigator.share({ title, text, files: [file] });
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy image to clipboard (desktop Chrome/Edge, some Android)
|
||||
if (navigator.clipboard && window.ClipboardItem) {
|
||||
try {
|
||||
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
|
||||
alert('Screenshot copied to clipboard');
|
||||
return;
|
||||
} catch (clipErr) {
|
||||
// Clipboard write failed (common on Android), fall through to download
|
||||
console.log('Clipboard write not allowed, downloading instead:', clipErr);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: prompt a download
|
||||
const dlUrl = URL.createObjectURL(blob);
|
||||
const a = Object.assign(document.createElement('a'), {
|
||||
href: dlUrl,
|
||||
download: 'share.png'
|
||||
});
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(dlUrl);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(`share failed: ${err?.message || err}`);
|
||||
} finally { }
|
||||
// Fallback: download.
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = Object.assign(document.createElement('a'), { href: url, download: filename });
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user