Files
zlqy/static/js/rich-editor.js
2026-02-27 10:37:11 +08:00

737 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* RichEditor - 共享富文本工具栏组件
* 用于聊天和论坛的输入区域
*/
// ========== 常量 ==========
const RE_FONTS = [
{ label: '默认', value: '' },
{ label: '宋体', value: '宋体,SimSun' },
{ label: '黑体', value: '黑体,SimHei' },
{ label: '楷体', value: '楷体,KaiTi' },
{ label: '仿宋', value: '仿宋,FangSong' },
{ label: '微软雅黑', value: '微软雅黑,Microsoft YaHei' },
{ label: '华文行楷', value: '华文行楷,STXingkai' },
{ label: '华文新魏', value: '华文新魏,STXinwei' },
{ label: '隶书', value: '隶书,LiSu' },
{ label: '幼圆', value: '幼圆,YouYuan' },
{ label: 'Consolas', value: 'Consolas,monospace' },
{ label: 'Georgia', value: 'Georgia,serif' },
{ label: 'Comic Sans', value: 'Comic Sans MS,cursive' },
{ label: 'Times New Roman', value: 'Times New Roman,serif' },
];
const RE_EMOJIS = {
'表情': ['😀','😁','😂','🤣','😊','😍','🥰','😘','😜','🤪'],
'手势': ['👍','👎','👏','🙌','🤝','✌️','🤞','👌','🫶','🙏'],
'心形': ['❤️','🧡','💛','💚','💙','💜','🖤','🤍','💖','💝'],
'动物': ['🐱','🐶','🐼','🦊','🐰','🐸','🐵','🦁','🐯','🐮'],
'物品': ['🎉','🎊','🔥','⭐','💡','📚','✏️','🏆','🎯','💻'],
'符号': ['✅','❌','⚠️','💯','🔴','🟢','🔵','⬆️','⬇️','➡️'],
};
const RE_COLORS = [
'#ef4444','#f97316','#eab308','#22c55e','#06b6d4',
'#3b82f6','#8b5cf6','#ec4899','#6b7280','#000000',
'#dc2626','#ea580c','#ca8a04','#16a34a','#0891b2',
'#2563eb','#7c3aed','#db2777','#9ca3af','#ffffff',
];
const RE_MATH_SYMBOLS = {
'基础运算': [
{ label: '+', tex: '+' },
{ label: '', tex: '-' },
{ label: '±', tex: '\\pm' },
{ label: '∓', tex: '\\mp' },
{ label: '×', tex: '\\times' },
{ label: '÷', tex: '\\div' },
{ label: '·', tex: '\\cdot' },
{ label: '=', tex: '=' },
{ label: '≠', tex: '\\neq' },
{ label: '≈', tex: '\\approx' },
{ label: '≡', tex: '\\equiv' },
{ label: '<', tex: '<' },
{ label: '>', tex: '>' },
{ label: '≤', tex: '\\leq' },
{ label: '≥', tex: '\\geq' },
{ label: '≪', tex: '\\ll' },
{ label: '≫', tex: '\\gg' },
{ label: '∝', tex: '\\propto' },
],
'上下标': [
{ label: 'x²', tex: '^{2}' },
{ label: 'x³', tex: '^{3}' },
{ label: 'xⁿ', tex: '^{}', cursor: -1 },
{ label: 'x₁', tex: '_{}', cursor: -1 },
{ label: 'xₙ', tex: '_{n}' },
{ label: 'x₁²', tex: '_{}^{}', cursor: -4 },
],
'分数根号': [
{ label: '分数', tex: '\\frac{}{}', cursor: -3 },
{ label: '√', tex: '\\sqrt{}', cursor: -1 },
{ label: 'ⁿ√', tex: '\\sqrt[n]{}', cursor: -1 },
{ label: 'ᵃ⁄ᵦ', tex: '\\dfrac{}{}', cursor: -3 },
],
'希腊字母': [
{ label: 'α', tex: '\\alpha' },
{ label: 'β', tex: '\\beta' },
{ label: 'γ', tex: '\\gamma' },
{ label: 'δ', tex: '\\delta' },
{ label: 'ε', tex: '\\epsilon' },
{ label: 'ζ', tex: '\\zeta' },
{ label: 'η', tex: '\\eta' },
{ label: 'θ', tex: '\\theta' },
{ label: 'λ', tex: '\\lambda' },
{ label: 'μ', tex: '\\mu' },
{ label: 'ξ', tex: '\\xi' },
{ label: 'π', tex: '\\pi' },
{ label: 'ρ', tex: '\\rho' },
{ label: 'σ', tex: '\\sigma' },
{ label: 'τ', tex: '\\tau' },
{ label: 'φ', tex: '\\varphi' },
{ label: 'ω', tex: '\\omega' },
{ label: 'Δ', tex: '\\Delta' },
{ label: 'Σ', tex: '\\Sigma' },
{ label: 'Ω', tex: '\\Omega' },
{ label: 'Φ', tex: '\\Phi' },
{ label: 'Π', tex: '\\Pi' },
{ label: 'Λ', tex: '\\Lambda' },
{ label: 'Γ', tex: '\\Gamma' },
],
'高等数学': [
{ label: '∑', tex: '\\sum_{i=1}^{n}', cursor: 0 },
{ label: '∏', tex: '\\prod_{i=1}^{n}', cursor: 0 },
{ label: '∫', tex: '\\int_{a}^{b}', cursor: 0 },
{ label: '∬', tex: '\\iint' },
{ label: '∮', tex: '\\oint' },
{ label: 'lim', tex: '\\lim_{x \\to }', cursor: -1 },
{ label: '∞', tex: '\\infty' },
{ label: 'log', tex: '\\log_{}', cursor: -1 },
{ label: 'ln', tex: '\\ln' },
{ label: 'sin', tex: '\\sin' },
{ label: 'cos', tex: '\\cos' },
{ label: 'tan', tex: '\\tan' },
{ label: 'arcsin', tex: '\\arcsin' },
{ label: 'arccos', tex: '\\arccos' },
{ label: 'arctan', tex: '\\arctan' },
{ label: '∂', tex: '\\partial' },
{ label: '∇', tex: '\\nabla' },
{ label: 'dx', tex: '\\mathrm{d}x' },
],
'集合逻辑': [
{ label: '∈', tex: '\\in' },
{ label: '∉', tex: '\\notin' },
{ label: '⊂', tex: '\\subset' },
{ label: '⊃', tex: '\\supset' },
{ label: '⊆', tex: '\\subseteq' },
{ label: '⊇', tex: '\\supseteq' },
{ label: '', tex: '\\cup' },
{ label: '∩', tex: '\\cap' },
{ label: '∅', tex: '\\emptyset' },
{ label: '∀', tex: '\\forall' },
{ label: '∃', tex: '\\exists' },
{ label: '¬', tex: '\\neg' },
{ label: '∧', tex: '\\land' },
{ label: '', tex: '\\lor' },
{ label: '⇒', tex: '\\Rightarrow' },
{ label: '⇔', tex: '\\Leftrightarrow' },
{ label: '→', tex: '\\to' },
{ label: '↦', tex: '\\mapsto' },
],
'括号矩阵': [
{ label: '()', tex: '\\left( \\right)', cursor: -8 },
{ label: '[]', tex: '\\left[ \\right]', cursor: -8 },
{ label: '{}', tex: '\\left\\{ \\right\\}', cursor: -9 },
{ label: '||', tex: '\\left| \\right|', cursor: -8 },
{ label: '⌈⌉', tex: '\\lceil \\rceil', cursor: -7 },
{ label: '⌊⌋', tex: '\\lfloor \\rfloor', cursor: -8 },
{ label: '矩阵', tex: '\\begin{pmatrix} \\\\ \\end{pmatrix}', cursor: -18 },
{ label: '行列式', tex: '\\begin{vmatrix} \\\\ \\end{vmatrix}', cursor: -18 },
{ label: '方程组', tex: '\\begin{cases} \\\\ \\end{cases}', cursor: -15 },
],
'其他符号': [
{ label: '…', tex: '\\cdots' },
{ label: '⋮', tex: '\\vdots' },
{ label: '⋱', tex: '\\ddots' },
{ label: '°', tex: '^{\\circ}' },
{ label: '‰', tex: '\\permil' },
{ label: '→', tex: '\\vec{}', cursor: -1 },
{ label: 'â', tex: '\\hat{}', cursor: -1 },
{ label: 'ā', tex: '\\bar{}', cursor: -1 },
{ label: 'ã', tex: '\\tilde{}', cursor: -1 },
{ label: '⊥', tex: '\\perp' },
{ label: '∥', tex: '\\parallel' },
{ label: '∠', tex: '\\angle' },
{ label: '△', tex: '\\triangle' },
{ label: '□', tex: '\\square' },
{ label: '⊕', tex: '\\oplus' },
{ label: '⊗', tex: '\\otimes' },
{ label: '★', tex: '\\star' },
{ label: '♠', tex: '\\spadesuit' },
],
};
const RE_FORMULA_TEMPLATES = [
{ label: '二次公式', tex: 'x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}' },
{ label: '勾股定理', tex: 'a^2 + b^2 = c^2' },
{ label: '求和公式', tex: '\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}' },
{ label: '二项式定理', tex: '(a+b)^n = \\sum_{k=0}^{n} \\binom{n}{k} a^{n-k} b^k' },
{ label: '欧拉公式', tex: 'e^{i\\pi} + 1 = 0' },
{ label: '三角恒等式', tex: '\\sin^2\\theta + \\cos^2\\theta = 1' },
{ label: '对数换底', tex: '\\log_a b = \\frac{\\ln b}{\\ln a}' },
{ label: '导数定义', tex: "f'(x) = \\lim_{h \\to 0} \\frac{f(x+h) - f(x)}{h}" },
{ label: '定积分', tex: '\\int_a^b f(x)\\,dx = F(b) - F(a)' },
{ label: '矩阵乘法', tex: '\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix} \\begin{pmatrix} e \\\\ f \\end{pmatrix} = \\begin{pmatrix} ae+bf \\\\ ce+df \\end{pmatrix}' },
{ label: '方程组', tex: '\\begin{cases} x + y = 5 \\\\ 2x - y = 1 \\end{cases}' },
{ label: '极限', tex: '\\lim_{x \\to \\infty} \\left(1 + \\frac{1}{x}\\right)^x = e' },
];
// ========== RichEditor 类 ==========
class RichEditor {
constructor(textareaId, options = {}) {
this.ta = document.getElementById(textareaId);
if (!this.ta) return;
this.compact = options.compact || false; // 紧凑模式(聊天用)
this.onEnter = options.onEnter || null;
this._buildToolbar();
this._buildPreview();
this._bindKeys();
this._bindPreviewUpdate();
}
_buildToolbar() {
const bar = document.createElement('div');
bar.className = 're-toolbar' + (this.compact ? ' re-compact' : '');
// 粗体
bar.appendChild(this._btn('B', '粗体', 're-bold', () => this._wrap('**', '**')));
// 斜体
bar.appendChild(this._btn('I', '斜体', 're-italic', () => this._wrap('*', '*')));
// 代码
bar.appendChild(this._btn('&lt;&gt;', '代码', 're-code', () => this._wrap('`', '`')));
bar.appendChild(this._sep());
// 颜色
const colorWrap = document.createElement('div');
colorWrap.className = 're-dropdown-wrap';
const colorBtn = this._btn('A', '文字颜色', 're-color-btn', () => this._togglePanel(colorWrap));
colorBtn.innerHTML = '<span style="border-bottom:2px solid #ef4444;padding-bottom:1px">A</span>';
colorWrap.appendChild(colorBtn);
colorWrap.appendChild(this._buildColorPanel(colorBtn));
bar.appendChild(colorWrap);
// 字体
const fontWrap = document.createElement('div');
fontWrap.className = 're-dropdown-wrap';
fontWrap.appendChild(this._btn('字', '字体', 're-font-btn', () => this._togglePanel(fontWrap)));
fontWrap.appendChild(this._buildFontPanel());
bar.appendChild(fontWrap);
bar.appendChild(this._sep());
// Emoji
const emojiWrap = document.createElement('div');
emojiWrap.className = 're-dropdown-wrap';
emojiWrap.appendChild(this._btn('😊', 'Emoji', 're-emoji-btn', () => this._togglePanel(emojiWrap)));
emojiWrap.appendChild(this._buildEmojiPanel());
bar.appendChild(emojiWrap);
// 公式编辑器按钮
bar.appendChild(this._sep());
const self = this;
bar.appendChild(this._btn('fx', '公式编辑器', 're-fx-btn', () => openMathEditor(self.ta)));
this.ta.parentNode.insertBefore(bar, this.ta);
this.toolbar = bar;
// 点击外部关闭面板
document.addEventListener('click', (e) => {
if (!bar.contains(e.target)) {
bar.querySelectorAll('.re-panel').forEach(p => p.classList.add('hidden'));
}
});
}
_btn(html, title, cls, onclick) {
const b = document.createElement('button');
b.type = 'button';
b.className = 're-btn ' + (cls || '');
b.title = title;
b.innerHTML = html;
b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onclick(); });
return b;
}
_sep() {
const s = document.createElement('span');
s.className = 're-sep';
return s;
}
_togglePanel(wrap) {
const panel = wrap.querySelector('.re-panel');
const wasHidden = panel.classList.contains('hidden');
this.toolbar.querySelectorAll('.re-panel').forEach(p => p.classList.add('hidden'));
if (wasHidden) panel.classList.remove('hidden');
}
_wrap(pre, suf) {
const s = this.ta.selectionStart, e = this.ta.selectionEnd;
const txt = this.ta.value;
const sel = txt.substring(s, e) || '文本';
this.ta.value = txt.substring(0, s) + pre + sel + suf + txt.substring(e);
this.ta.focus();
this.ta.selectionStart = s + pre.length;
this.ta.selectionEnd = s + pre.length + sel.length;
this._refreshPreview();
}
_insertAt(text, cursorOffset) {
const s = this.ta.selectionStart;
const v = this.ta.value;
this.ta.value = v.substring(0, s) + text + v.substring(s);
this.ta.focus();
this.ta.selectionStart = this.ta.selectionEnd = s + text.length + (cursorOffset || 0);
this._refreshPreview();
}
_bindKeys() {
if (!this.onEnter) return;
this.ta.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.onEnter();
}
});
}
_buildPreview() {
// 实时预览面板,插在 textarea 后面
const pv = document.createElement('div');
pv.className = 're-live-preview';
pv.innerHTML = '<div class="re-pv-label">预览</div><div class="re-pv-body"></div>';
// 插到 textarea 的父容器末尾border 容器内)
this.ta.parentNode.appendChild(pv);
this.previewEl = pv.querySelector('.re-pv-body');
}
_bindPreviewUpdate() {
let timer = null;
const update = () => {
if (!this.previewEl) return;
const val = this.ta.value.trim();
if (!val) { this.previewEl.innerHTML = '<span style="color:#94a3b8;font-size:13px">输入内容后这里会显示实时预览...</span>'; return; }
this.previewEl.innerHTML = renderRichContent(val);
};
this.ta.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(update, 150); });
update();
}
_refreshPreview() {
if (!this.previewEl) return;
const val = this.ta.value.trim();
if (!val) { this.previewEl.innerHTML = '<span style="color:#94a3b8;font-size:13px">输入内容后这里会显示实时预览...</span>'; return; }
this.previewEl.innerHTML = renderRichContent(val);
}
// ===== 颜色面板 =====
_buildColorPanel(colorBtn) {
const panel = document.createElement('div');
panel.className = 're-panel re-color-panel hidden';
RE_COLORS.forEach(c => {
const swatch = document.createElement('button');
swatch.type = 'button';
swatch.className = 're-swatch';
swatch.style.background = c;
if (c === '#ffffff') swatch.style.border = '1px solid #e2e8f0';
swatch.addEventListener('click', (e) => {
e.stopPropagation();
this._wrap(`[color:${c}]`, '[/color]');
colorBtn.querySelector('span').style.borderBottomColor = c;
panel.classList.add('hidden');
});
panel.appendChild(swatch);
});
return panel;
}
// ===== 字体面板 =====
_buildFontPanel() {
const panel = document.createElement('div');
panel.className = 're-panel re-font-panel hidden';
RE_FONTS.forEach(f => {
const item = document.createElement('button');
item.type = 'button';
item.className = 're-font-item';
item.textContent = f.label;
if (f.value) item.style.fontFamily = f.value;
item.addEventListener('click', (e) => {
e.stopPropagation();
if (f.value) {
this._wrap(`[font:${f.label}]`, '[/font]');
}
panel.classList.add('hidden');
});
panel.appendChild(item);
});
return panel;
}
// ===== Emoji 面板 =====
_buildEmojiPanel() {
const panel = document.createElement('div');
panel.className = 're-panel re-emoji-panel hidden';
const tabs = document.createElement('div');
tabs.className = 're-emoji-tabs';
const body = document.createElement('div');
body.className = 're-emoji-body';
const categories = Object.keys(RE_EMOJIS);
categories.forEach((cat, i) => {
const tab = document.createElement('button');
tab.type = 'button';
tab.className = 're-emoji-tab' + (i === 0 ? ' active' : '');
tab.textContent = cat;
tab.addEventListener('click', (e) => {
e.stopPropagation();
tabs.querySelectorAll('.re-emoji-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
this._showEmojiCat(body, cat);
});
tabs.appendChild(tab);
});
panel.appendChild(tabs);
panel.appendChild(body);
this._showEmojiCat(body, categories[0]);
return panel;
}
_showEmojiCat(body, cat) {
body.innerHTML = '';
RE_EMOJIS[cat].forEach(em => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 're-emoji-item';
btn.textContent = em;
btn.addEventListener('click', (e) => {
e.stopPropagation();
this._insertAt(em);
});
body.appendChild(btn);
});
}
}
// ========== MathFormulaEditor 单例类 ==========
class MathFormulaEditor {
constructor() {
if (MathFormulaEditor._instance) return MathFormulaEditor._instance;
MathFormulaEditor._instance = this;
this._built = false;
this._target = null;
this._onInsert = null;
this._debounceTimer = null;
}
open(targetTextarea, onInsert) {
this._target = targetTextarea;
this._onInsert = onInsert || null;
if (!this._built) this._buildDOM();
this._input.value = '';
this._previewEl.innerHTML = '<span style="color:#94a3b8">在上方输入 LaTeX 公式,这里实时预览</span>';
this._overlay.classList.remove('hidden');
document.body.style.overflow = 'hidden';
// 激活第一个符号分类 tab
const firstTab = this._symbolTabs.querySelector('.re-emoji-tab');
if (firstTab) firstTab.click();
setTimeout(() => this._input.focus(), 50);
}
close() {
this._overlay.classList.add('hidden');
document.body.style.overflow = '';
}
_buildDOM() {
this._built = true;
const overlay = document.createElement('div');
overlay.className = 're-math-editor-overlay hidden';
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close(); });
const modal = document.createElement('div');
modal.className = 're-math-editor-modal';
// Header
const header = document.createElement('div');
header.className = 're-math-editor-header';
header.innerHTML = '<span style="font-weight:600;font-size:16px;color:#1e293b">数学公式编辑器</span>';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 're-math-editor-close';
closeBtn.innerHTML = '&times;';
closeBtn.addEventListener('click', () => this.close());
header.appendChild(closeBtn);
modal.appendChild(header);
// Body
const body = document.createElement('div');
body.className = 're-math-editor-body';
// LaTeX input
const inputLabel = document.createElement('div');
inputLabel.style.cssText = 'font-size:12px;color:#64748b;margin-bottom:4px;font-weight:500';
inputLabel.textContent = 'LaTeX 公式';
body.appendChild(inputLabel);
const input = document.createElement('textarea');
input.className = 're-math-editor-input';
input.rows = 3;
input.placeholder = '输入 LaTeX 公式,如 \\frac{a}{b}、x^2 + y^2 = r^2';
input.addEventListener('input', () => this._updatePreview());
this._input = input;
body.appendChild(input);
// Preview
const pvLabel = document.createElement('div');
pvLabel.style.cssText = 'font-size:12px;color:#64748b;margin:12px 0 4px;font-weight:500';
pvLabel.textContent = '实时预览';
body.appendChild(pvLabel);
const preview = document.createElement('div');
preview.className = 're-math-editor-preview';
this._previewEl = preview;
body.appendChild(preview);
// Symbol panel
const symLabel = document.createElement('div');
symLabel.style.cssText = 'font-size:12px;color:#64748b;margin:12px 0 4px;font-weight:500';
symLabel.textContent = '符号面板';
body.appendChild(symLabel);
// PLACEHOLDER_SYMBOL_PANEL
const symTabs = document.createElement('div');
symTabs.className = 're-emoji-tabs';
symTabs.style.cssText = 'border:1px solid #e2e8f0;border-radius:8px 8px 0 0';
this._symbolTabs = symTabs;
const symGrid = document.createElement('div');
symGrid.className = 're-math-grid';
symGrid.style.cssText = 'border:1px solid #e2e8f0;border-top:none;border-radius:0 0 8px 8px;margin-bottom:0';
const categories = Object.keys(RE_MATH_SYMBOLS);
categories.forEach((cat, i) => {
const tab = document.createElement('button');
tab.type = 'button';
tab.className = 're-emoji-tab' + (i === 0 ? ' active' : '');
tab.textContent = cat;
tab.addEventListener('click', (e) => {
e.stopPropagation();
symTabs.querySelectorAll('.re-emoji-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
this._showSymCat(symGrid, cat);
});
symTabs.appendChild(tab);
});
body.appendChild(symTabs);
body.appendChild(symGrid);
this._showSymCat(symGrid, categories[0]);
// Template section
const tplLabel = document.createElement('div');
tplLabel.style.cssText = 'font-size:12px;color:#64748b;margin:14px 0 6px;font-weight:500';
tplLabel.textContent = '常用公式模板';
body.appendChild(tplLabel);
const tplGrid = document.createElement('div');
tplGrid.className = 're-math-editor-templates';
RE_FORMULA_TEMPLATES.forEach(tpl => {
const card = document.createElement('button');
card.type = 'button';
card.className = 're-math-editor-tpl-card';
const texDiv = document.createElement('div');
texDiv.className = 're-math-editor-tpl-tex';
if (window.katex) {
try { texDiv.innerHTML = katex.renderToString(tpl.tex, { throwOnError: false, displayMode: false }); }
catch(e) { texDiv.textContent = tpl.tex; }
} else { texDiv.textContent = tpl.tex; }
const labelDiv = document.createElement('div');
labelDiv.className = 're-math-editor-tpl-label';
labelDiv.textContent = tpl.label;
card.appendChild(texDiv);
card.appendChild(labelDiv);
card.addEventListener('click', () => this._applyTemplate(tpl.tex));
tplGrid.appendChild(card);
});
body.appendChild(tplGrid);
modal.appendChild(body);
// Footer
const footer = document.createElement('div');
footer.className = 're-math-editor-footer';
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 're-math-editor-btn-cancel';
cancelBtn.textContent = '取消';
cancelBtn.addEventListener('click', () => this.close());
const confirmBtn = document.createElement('button');
confirmBtn.type = 'button';
confirmBtn.className = 're-math-editor-btn-confirm';
confirmBtn.textContent = '插入公式';
confirmBtn.addEventListener('click', () => this._confirm());
footer.appendChild(cancelBtn);
footer.appendChild(confirmBtn);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
this._overlay = overlay;
// ESC to close
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !this._overlay.classList.contains('hidden')) this.close();
});
}
_updatePreview() {
clearTimeout(this._debounceTimer);
this._debounceTimer = setTimeout(() => {
const tex = this._input.value.trim();
if (!tex) {
this._previewEl.innerHTML = '<span style="color:#94a3b8">在上方输入 LaTeX 公式,这里实时预览</span>';
return;
}
if (window.katex) {
try {
this._previewEl.innerHTML = katex.renderToString(tex, { throwOnError: true, displayMode: true });
} catch(e) {
this._previewEl.innerHTML = '<span style="color:#ef4444;font-size:13px">语法错误: ' + e.message.replace(/</g,'&lt;') + '</span>';
}
} else {
this._previewEl.textContent = tex;
}
}, 150);
}
_insertSymbol(tex, cursor) {
const start = this._input.selectionStart;
const val = this._input.value;
this._input.value = val.substring(0, start) + tex + val.substring(start);
this._input.focus();
this._input.selectionStart = this._input.selectionEnd = start + tex.length + (cursor || 0);
this._updatePreview();
}
_applyTemplate(tex) {
this._input.value = tex;
this._input.focus();
this._input.selectionStart = this._input.selectionEnd = tex.length;
this._updatePreview();
}
_confirm() {
const tex = this._input.value.trim();
if (!tex) { this.close(); return; }
const formula = '$' + tex + '$';
if (this._target) {
const start = this._target.selectionStart || this._target.value.length;
const val = this._target.value;
this._target.value = val.substring(0, start) + formula + val.substring(start);
this._target.selectionStart = this._target.selectionEnd = start + formula.length;
this._target.dispatchEvent(new Event('input', { bubbles: true }));
}
if (typeof this._onInsert === 'function') this._onInsert(formula);
this.close();
}
_showSymCat(grid, cat) {
grid.innerHTML = '';
RE_MATH_SYMBOLS[cat].forEach(sym => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 're-math-item';
btn.textContent = sym.label;
btn.title = sym.tex;
btn.addEventListener('click', (e) => {
e.stopPropagation();
this._insertSymbol(sym.tex, sym.cursor || 0);
});
grid.appendChild(btn);
});
}
}
function openMathEditor(textarea, callback) {
new MathFormulaEditor().open(textarea, callback);
}
// ========== 全局渲染函数 ==========
const RE_FONT_MAP = {};
RE_FONTS.forEach(f => { if (f.value) RE_FONT_MAP[f.label] = f.value; });
function _reEsc(text) {
const d = document.createElement('div');
d.textContent = text;
return d.innerHTML;
}
function renderRichContent(text) {
if (!text) return '';
// 1. 先提取公式,避免 HTML 转义破坏 LaTeX
const formulas = [];
let raw = text.replace(/\$([^$\n]+?)\$/g, function(m, tex) {
const idx = formulas.length;
formulas.push(tex);
return '\x00MATH' + idx + '\x00';
});
// 2. HTML 转义
let h = _reEsc(raw);
// 3. 图片标签 [img:url]
h = h.replace(/\[img:(\/static\/uploads\/[^\]]+)\]/g,
'<img src="$1" class="max-w-full rounded-lg border border-slate-200 my-2 cursor-pointer" style="max-height:400px" onclick="window.open(this.src)">');
// 4. 还原公式并用 KaTeX 渲染
h = h.replace(/\x00MATH(\d+)\x00/g, function(m, idx) {
const tex = formulas[parseInt(idx)];
if (window.katex) {
try {
return katex.renderToString(tex, { throwOnError: false });
} catch(e) { return '$' + _reEsc(tex) + '$'; }
}
// KaTeX 未加载时显示原始公式并标记 class 供后续渲染
return '<span class="re-pending-math" data-tex="' + _reEsc(tex) + '">$' + _reEsc(tex) + '$</span>';
});
// 5. 粗体 **...**
h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// 6. 斜体 *...*
h = h.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>');
// 7. 行内代码 `...`
h = h.replace(/`([^`]+?)`/g, '<code class="re-inline-code">$1</code>');
// 8. 颜色 [color:#hex]...[/color]
h = h.replace(/\[color:(#[0-9a-fA-F]{3,8})\]([\s\S]*?)\[\/color\]/g,
'<span style="color:$1">$2</span>');
// 9. 字体 [font:name]...[/font]
h = h.replace(/\[font:([^\]]+)\]([\s\S]*?)\[\/font\]/g, function(m, name, content) {
const family = RE_FONT_MAP[name];
if (family) return '<span style="font-family:' + _reEsc(family) + '">' + content + '</span>';
return content;
});
return h;
}
// KaTeX 延迟加载后,重新渲染页面上未渲染的公式
function _reRenderPendingMath() {
if (!window.katex) return;
document.querySelectorAll('.re-pending-math').forEach(el => {
try {
el.innerHTML = katex.renderToString(el.dataset.tex, { throwOnError: false });
el.classList.remove('re-pending-math');
} catch(e) {}
});
}
// 定期检查 KaTeX 是否加载完成
(function _waitKatex() {
if (window.katex) { _reRenderPendingMath(); return; }
setTimeout(_waitKatex, 500);
})();