Files
alar.ink/static/share.js
T

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);
}