/**
* 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('<>', '代码', '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 = 'A';
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 = '
预览
';
// 插到 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 = '输入内容后这里会显示实时预览...'; 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 = '输入内容后这里会显示实时预览...'; 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 = '在上方输入 LaTeX 公式,这里实时预览';
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 = '数学公式编辑器';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 're-math-editor-close';
closeBtn.innerHTML = '×';
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 = '在上方输入 LaTeX 公式,这里实时预览';
return;
}
if (window.katex) {
try {
this._previewEl.innerHTML = katex.renderToString(tex, { throwOnError: true, displayMode: true });
} catch(e) {
this._previewEl.innerHTML = '语法错误: ' + e.message.replace(/';
}
} 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,
'
');
// 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 '$' + _reEsc(tex) + '$';
});
// 5. 粗体 **...**
h = h.replace(/\*\*(.+?)\*\*/g, '$1');
// 6. 斜体 *...*
h = h.replace(/(?$1');
// 7. 行内代码 `...`
h = h.replace(/`([^`]+?)`/g, '$1');
// 8. 颜色 [color:#hex]...[/color]
h = h.replace(/\[color:(#[0-9a-fA-F]{3,8})\]([\s\S]*?)\[\/color\]/g,
'$2');
// 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 '' + content + '';
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);
})();