Refactor all templates to dictpress v5 (Rust/Tera templates).

This commit is contained in:
2026-03-26 14:59:30 +05:30
parent c07b8331c3
commit b13a23bbc4
22 changed files with 1032 additions and 1585 deletions
+1 -6
View File
@@ -1,8 +1,3 @@
// Custom language code mapping for govarnam.
window.autoCompLangCodes = {
"kannada": "kn"
}
function hasKannadaChar(str) {
return /[\u0C80-\u0CFF]/.test(str);
}
@@ -57,4 +52,4 @@ function hasKannadaChar(str) {
defs[i].appendChild(s);
}
})();
})();
+16 -3
View File
@@ -7,6 +7,9 @@ function autocomp(el, options = {}) {
// Disable browser's default autocomplete behaviour on the input.
el.autocomplete = "off";
el.setAttribute("role", "combobox");
el.setAttribute("aria-autocomplete", "list");
el.setAttribute("aria-expanded", "false");
// Attach all the events required for the interactions in one go.
["input", "keydown", "blur"].forEach(k => el.addEventListener(k, handleEvent));
@@ -85,7 +88,9 @@ function autocomp(el, options = {}) {
});
box.classList.add("autocomp");
box.setAttribute("role", "listbox");
el.parentNode.insertBefore(box, el.nextSibling);
el.setAttribute("aria-expanded", "true");
}
function renderResults() {
@@ -93,6 +98,8 @@ function autocomp(el, options = {}) {
items.forEach((item, idx) => {
const div = document.createElement("div");
div.classList.add("autocomp-item");
div.setAttribute("role", "option");
div.setAttribute("aria-selected", idx === cur ? "true" : "false");
// If there's a custom renderer callback, use it, else, simply insert the value/text as-is.
opt.onRender ? div.appendChild(opt.onRender(item)) : div.innerText = item;
@@ -110,11 +117,16 @@ function autocomp(el, options = {}) {
// Remove the previous item's highlight;
const prev = box.querySelector(`:nth-child(${cur + 1})`);
prev?.classList.remove("autocomp-sel");
if (prev) {
prev.classList.remove("autocomp-sel");
prev.setAttribute("aria-selected", "false");
}
// Increment the cursor and highlight the next item, cycled between [0, n].
cur = (cur + direction + items.length) % items.length;
box.querySelector(`:nth-child(${cur + 1})`).classList.add("autocomp-sel");
const next = box.querySelector(`:nth-child(${cur + 1})`);
next.classList.add("autocomp-sel");
next.setAttribute("aria-selected", "true");
}
function select(idx) {
@@ -122,7 +134,7 @@ function autocomp(el, options = {}) {
return;
}
val = opt.onSelect(items[idx], items);
val = opt.onSelect(items[idx]);
el.value = val || items[idx];
}
@@ -133,5 +145,6 @@ function autocomp(el, options = {}) {
box.remove();
box = null;
}
el.setAttribute("aria-expanded", "false");
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#aaa" width="12" height="12" viewBox="0 0 15.867 15.79" xmlns:v="https://vecta.io/nano"><path d="M9.866 2.635l3.228 3.228-8.17 8.17-3.226-3.228zm5.677-.778L14.104.417a1.43 1.43 0 0 0-2.018 0l-1.379 1.379 3.228 3.228 1.608-1.608a1.1 1.1 0 0 0 0-1.559zM.009 15.342c-.059.264.18.501.444.437l3.597-.872-3.226-3.228z"/></svg>

After

Width:  |  Height:  |  Size: 366 B

+1 -2
View File
@@ -1,2 +1 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#aaa" width="18px" height="18px" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg"><path d="M30.3 13.7L25 8.4l-5.3 5.3-1.4-1.4L25 5.6l6.7 6.7z"/><path d="M24 7h2v21h-2z"/><path d="M35 40H15c-1.7 0-3-1.3-3-3V19c0-1.7 1.3-3 3-3h7v2h-7c-.6 0-1 .4-1 1v18c0 .6.4 1 1 1h20c.6 0 1-.4 1-1V19c0-.6-.4-1-1-1h-7v-2h7c1.7 0 3 1.3 3 3v18c0 1.7-1.3 3-3 3z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="#aaa" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 10V1.5M5 4l3-2.5L11 4"/><path d="M3 7v6.5h10V7"/></svg>

Before

Width:  |  Height:  |  Size: 485 B

After

Width:  |  Height:  |  Size: 242 B

-135
View File
@@ -1,135 +0,0 @@
.container {
position: relative;
width: 100%;
max-width: 960px;
margin: 0 auto;
box-sizing: border-box;
}
.row {
box-sizing: border-box;
display: flex;
flex: 0 1 auto;
flex-flow: row wrap;
}
.row.nogutter {
margin-left: 0;
margin-right: 0;
}
.row.nogutter > .columns {
padding-left: 0;
padding-right: 0;
}
.columns {
box-sizing: border-box;
flex: 0 1 auto;
min-width: 0;
}
.one {
flex-basis: 8.33333333%;
}
.two {
flex-basis: 16.66666667%;
}
.three {
flex-basis: 25%;
}
.four {
flex-basis: 33.3333333333%;
}
.five {
flex-basis: 41.66666667%;
}
.six {
flex-basis: 50%;
}
.seven {
flex-basis: 58.33333333%;
}
.eight {
flex-basis: 66.66666667%;
}
.nine {
flex-basis: 75%;
}
.ten {
flex-basis: 83.33333333%;
}
.eleven {
flex-basis: 91.66666667%;
}
.twelve {
flex-basis: 100%;
}
.col-offset-0 {
margin-left: 0;
}
.col-offset-1 {
margin-left: 8.33333333%;
}
.col-offset-2 {
margin-left: 16.66666667%;
}
.col-offset-3 {
margin-left: 25%;
}
.col-offset-4 {
margin-left: 33.33333333%;
}
.col-offset-5 {
margin-left: 41.66666667%;
}
.col-offset-6 {
margin-left: 50%;
}
.col-offset-7 {
margin-left: 58.33333333%;
}
.col-offset-8 {
margin-left: 66.66666667%;
}
.col-offset-9 {
margin-left: 75%;
}
.col-offset-10 {
margin-left: 83.33333333%;
}
.col-offset-11 {
margin-left: 91.66666667%;
}
.between {
justify-content: space-between;
}
.end {
justify-content: flex-end;
}
.around {
justify-content: space-around;
}
.row-align-center {
align-items: center;
}
.space-right {
margin-right: 10px;
}
.space-left {
margin-left: 10px;
}
.space-bottom {
margin-bottom: 10px;
}
.space-top {
margin-top: 10px;
}
@media (max-width: 980px) {
.columns {
flex: 1 1 auto;
}
.offset-0,
.offset-1,
.offset-2 {
margin: unset;
}
.container {
padding: 0 15px;
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" xmlns:v="https://vecta.io/nano"><path fill="#aaa" d="M48 0c-4.285 0-8.298 1.654-11.308 4.692L24.678 16.678c-3.037 3.01-4.692 7.051-4.692 11.308 0 5.478 2.766 10.495 7.376 13.478.705.461 1.519.569 2.278.407a3.03 3.03 0 0 0 1.871-1.302c.434-.705.569-1.519.407-2.251-.163-.759-.624-1.437-1.302-1.898-2.902-1.844-4.61-4.99-4.61-8.407a9.95 9.95 0 0 1 2.929-7.078L40.922 8.949C42.82 7.051 45.315 6.02 48 6.02a10.02 10.02 0 0 1 10.007 10.007 9.95 9.95 0 0 1-2.929 7.078L49.383 28.8a3.06 3.06 0 0 0-.895 2.169c0 .759.298 1.492.895 2.061.597.597 1.356.868 2.115.868s1.546-.298 2.115-.868l5.695-5.695A15.94 15.94 0 0 0 64 16c0-8.814-7.186-16-16-16zM36.61 22.536c-.705-.461-1.519-.569-2.278-.407a3.03 3.03 0 0 0-1.871 1.302c-.434.705-.569 1.519-.407 2.251.163.759.624 1.437 1.302 1.898 2.902 1.844 4.61 4.99 4.61 8.407a9.95 9.95 0 0 1-2.929 7.078L23.051 55.078c-1.898 1.898-4.393 2.929-7.078 2.929A10.02 10.02 0 0 1 5.966 48a9.95 9.95 0 0 1 2.929-7.078l5.695-5.695a3.06 3.06 0 0 0 .895-2.169c0-.732-.298-1.492-.895-2.061-.597-.597-1.356-.868-2.115-.868s-1.546.298-2.115.868l-5.695 5.695A15.87 15.87 0 0 0 0 48c0 8.814 7.186 16 16 16 4.285 0 8.298-1.654 11.308-4.692l12.014-11.986c3.037-3.01 4.692-7.051 4.692-11.308-.027-5.478-2.766-10.522-7.403-13.478z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+84 -120
View File
@@ -1,15 +1,3 @@
async function screenshotElement(element) {
const canvas = await html2canvas(element);
canvas.toBlob(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'screenshot.png';
a.click();
URL.revokeObjectURL(url);
});
}
(() => {
const elForm = document.querySelector("form.search-form");
const elQ = document.querySelector("#q");
@@ -34,7 +22,7 @@ async function screenshotElement(element) {
let val = q.trim();
const uri = elForm.getAttribute("action");
document.location.href = `${uri}/${encodeURIComponent(val).replace(/%20/g, "+")}`;
document.location.href = `${uri}/${encodeURIComponent(val).replace(/%20/g, "-")}`;
}
// ===================================
@@ -82,7 +70,7 @@ async function screenshotElement(element) {
// fetch() content from the dataset data.guid from /api/entry/{guid}
// and populate the div.more innerHTML with the content.
if (state === "block" && !el.dataset.fetched) {
container.innerHTML = `<div class="loader"></div>`;
container.innerHTML = `<div aria-busy="true"></div>`;
fetch(`${window._ROOT_URL}/api/dictionary/entries/${el.dataset.entryGuid}`)
.then((resp) => resp.json())
@@ -127,52 +115,11 @@ async function screenshotElement(element) {
elQ.select();
})();
// Screenshot sharing.
(() => {
document.querySelectorAll("a.export").forEach((el) => {
el.onclick = async (e) => {
e.preventDefault();
const guid = el.dataset.guid;
const entryEl = document.querySelector(`.entry[data-guid='${guid}']`);
if (!entryEl) {
alert("Could not find entry to export");
return;
}
const title = entryEl.dataset.head;
// Make the filename by stripping spaces from the head word(s).
const filename = title.replace(/\s+/g, "_").toLowerCase();
try {
await shareDOM(entryEl, `${title} meaning`, `${localStorage.from_lang} to ${localStorage.to_lang} meaning\n\n${window.location.href}`, `${filename}.png`);
} catch (err) {
console.error("Error sharing entry:", err);
alert(`Error sharing entry: ${err?.message || err}`);
}
};
});
})();
// Play audio.
(() => {
document.querySelectorAll("a[data-audio]").forEach((el) => {
el.onclick = (e) => {
e.preventDefault();
const audio = new Audio(el.getAttribute("href"));
audio.play().catch((err) => {
console.error("error playing audio:", err);
alert("error playing audio");
});
};
});
})();
// Submission form.
(() => {
function filterTypes(e) {
// Filter the types select field with elements that are supported by the language.
const types = e.target.closest("fieldset").querySelector("select[name=relation_type]");
const types = e.target.closest("[data-relation-controls]").querySelector("select[name=relation_type]");
types.querySelectorAll("option").forEach((o) => o.style.display = "none");
types.querySelectorAll(`option[data-lang=${e.target.value}]`).forEach((o) => o.style.display = "block");
types.selectedIndex = 1;
@@ -207,64 +154,59 @@ async function screenshotElement(element) {
}
})();
// Edit form.
// Edit form using ot-dropdown.
(() => {
document.querySelectorAll(".edit").forEach((o) => {
o.onclick = ((e) => {
e.preventDefault();
const btn = e.target;
const tpl = document.querySelector("#tpl-form-comments");
if (!tpl) return;
// Form is already open.
if (btn.close) {
btn.close();
return;
}
let counter = 0;
document.querySelectorAll("[data-edit-from]").forEach((btn) => {
const parent = btn.parentNode;
const form = document.querySelector(".form-comments").cloneNode(true);
o.parentNode.appendChild(form);
form.style.display = "block";
// Clone template content and give the popover a unique ID.
const popoverId = `form-comments-${counter++}`;
const form = tpl.content.firstElementChild.cloneNode(true);
form.id = popoverId;
const txt = form.querySelector("textarea");
txt.focus();
txt.onkeydown = (e) => {
if (e.key === "Escape" && txt.value === "") {
btn.close();
}
};
// Wire the button as the popover trigger.
btn.setAttribute("popovertarget", popoverId);
btn.close = () => {
btn.close = null;
form.remove();
};
// Build <ot-dropdown> with children fully assembled before inserting into DOM
// so that init() finds [popovertarget] and [popover].
const dropdown = document.createElement("ot-dropdown");
dropdown.appendChild(btn);
dropdown.appendChild(form);
parent.appendChild(dropdown);
// Handle form submission.
form.onsubmit = () => {
fetch(`${window._ROOT_URL}/api/submissions/comments`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
from_guid: btn.dataset.from,
to_guid: btn.dataset.to,
comments: txt.value
})
}).catch((err) => {
alert(`Error submitting: ${err}`);
});
const txt = form.querySelector("textarea");
alert(form.dataset.success);
btn.close();
};
// Handle submission.
form.querySelector("button.submit-comment").onclick = () => {
fetch(`${window._ROOT_URL}/api/submissions/comments`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
from_guid: btn.dataset.editFrom,
to_guid: btn.dataset.editTo,
comments: txt.value
})
}).catch((err) => {
alert(`Error submitting: ${err}`);
});
form.querySelector("button.close").onclick = btn.close;
});
})
alert(form.dataset.success);
form.hidePopover();
};
form.querySelector("button.close").onclick = () => form.hidePopover();
});
})();
// Autocomplete.
(() => {
if(!autocomp) {
if (!autocomp) {
return;
}
@@ -273,37 +215,25 @@ async function screenshotElement(element) {
let debounce;
autocomp(elQ, {
autoSelect: elQ.dataset.autocompAutoselect === "true",
autoSelect: false,
onQuery: async (val) => {
const langCode = localStorage.from_lang;
if (!langCode) {
return;
}
const shortcode = autoCompLangCodes?.[langCode] ?? langCode;
clearTimeout(debounce);
return new Promise(resolve => {
debounce = setTimeout(async () => {
const response = await fetch(`${_ROOT_URL}/atl/${shortcode}/${val.toLowerCase()}`);
const response = await fetch(`${_ROOT_URL}/api/autocomplete/${langCode}/${val}`);
const data = await response.json();
const a = data.greedy_tokenized.map(item => item.word).slice(0, 3).sort((a, b) => a.length - b.length);
const b = data.dictionary_suggestions.map(item => item.word).slice(0, 6).sort((a, b) => a.length - b.length);
const suggestions = data.data.map(item => item.content[0]);
debounce = null;
resolve([...new Set(a.concat(b))]);
resolve(suggestions);
}, 50);
});
},
onSelect: (val, items) => {
// If the val is English, then pick the first item from items and use that.
if (/^[A-Za-z0-9\-,'" ]+$/.test(val) && items.length > 0) {
val = items[0];
}
onSelect: (val) => {
// autocomp search isn't complete. Use the user's input instead of autocomp selection.
if (val) {
elQ.value = val;
}
@@ -313,3 +243,37 @@ async function screenshotElement(element) {
}
});
})();
// Audio playback.
document.querySelectorAll("[data-audio]").forEach((el) => {
el.onclick = (e) => {
e.preventDefault();
const audio = new Audio(el.dataset.src);
audio.play().catch((err) => {
console.error("error playing audio:", err);
alert("error playing audio");
});
};
});
// Scroll to hash on load.
window.setTimeout(() => {
if (window.location.hash) {
document.querySelector(window.location.hash)?.scrollIntoView();
}
}, 100);
// Screenshot share.
document.querySelectorAll("[data-share-entry]").forEach((el) => {
el.onclick = async (e) => {
e.preventDefault();
const entryEl = document.getElementById(el.dataset.shareEntry);
if (!entryEl) return;
try {
await shareEntry(entryEl);
} catch (err) {
console.error("Error sharing entry:", err);
alert(`Error sharing: ${err?.message || err}`);
}
};
});
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+194 -351
View File
@@ -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);
}
+421 -699
View File
File diff suppressed because it is too large Load Diff