208 lines
5.9 KiB
JavaScript
208 lines
5.9 KiB
JavaScript
// Canvas-based entry card screenshot and share.
|
|
|
|
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}`;
|
|
|
|
// 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;
|
|
}
|
|
|
|
return y + pad - 4;
|
|
}
|
|
|
|
// 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);
|
|
|
|
// 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();
|
|
|
|
doLayout(ctx);
|
|
|
|
return new Promise((resolve) => {
|
|
canvas.toBlob((blob) => resolve(blob), 'image/png');
|
|
});
|
|
}
|
|
|
|
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 {
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|