/** * 编辑器内容 ↔ 保存格式的转换。 * * 编辑器里把公式、注释渲染成可读的样子(方便写作时看到最终效果), * 但落库必须回到短代码,服务端才能稳定渲染。隐藏内容本身就是 * 短代码 / 前台结构,不需要转换。 * * 代码块例外:前台由 EnlighterJS 按「全局功能 → 代码高亮」统一高亮,认的是 * `pre > code`,并把源码里的换行当作分行;而 TinyMCE 把 `pre > code` 当成 * 行内边界,光标落不进去、输入会被拆成段落。因此编辑器里用裸 `
` 编辑, * 保存前套回 `pre > code` 并把 `
` 还原成换行符。 * * @package Jio */ const escapeAttr = (value: string): string => value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); const decodeEntities = (value: string): string => { const area = document.createElement('textarea'); area.innerHTML = value; return area.value; }; /** * 读出代码块的源码行。 * * 两种形态都要认:编辑器里的裸 ``(换行是 `
`),以及前台落库的 * `pre > code`(换行是文本里的 `\n`)。`` 只是容器,直接走进去读, * 不重复计数。 */ const readCodeLines = (pre: Element): string[] => { const lines: string[] = []; let current = ''; const flush = (): void => { lines.push(current); current = ''; }; const walk = (node: Node): void => { node.childNodes.forEach((child) => { if (child.nodeType === 3) { String(child.nodeValue || '') .replace(/ /g, ' ') .split('\n') .forEach((part, index) => { if (index > 0) { flush(); } current += part; }); return; } if (child.nodeType !== 1) { return; } if ((child as Element).nodeName === 'BR') { flush(); return; } walk(child); }); }; walk(pre); flush(); // 末尾的空行只是排版残留(裸 pre 收尾的
、落库时补的换行符)。 while (lines.length > 0 && lines[lines.length - 1].trim() === '') { lines.pop(); } return lines; }; /** * 编辑器里的裸 `` → 前台的 `pre > code`。 * * `
` 与文本里的换行都换成分行符:Enlighter 直接按文本切行,留着 `
` * 会把整块压成一行、长行溢出卡片。 */ const serializeCodeBlock = (pre: Element): void => { const lines = readCodeLines(pre); const code = document.createElement('code'); code.textContent = lines.length === 0 ? '' : `${lines.join('\n')}\n`; pre.replaceChildren(code); }; /** * 前台 `pre > code` → 编辑器里可直接输入的裸 ``。 */ const hydrateCodeBlock = (pre: Element): void => { const lines = readCodeLines(pre); pre.replaceChildren(); if (lines.length === 0) { pre.appendChild(document.createElement('br')); return; } lines.forEach((line, index) => { if (index > 0) { pre.appendChild(document.createElement('br')); } if (line !== '') { pre.appendChild(document.createTextNode(line)); } }); }; /** * 保存前:把编辑器里的可视化块换回短代码 / 纯标记。 */ export function serializeBlocks(html: string): string { if (!html) { return html; } const container = document.createElement('div'); container.innerHTML = html; // 代码块:裸 pre → 前台的 pre > code(换行写成换行符)。 container.querySelectorAll('pre').forEach((pre) => serializeCodeBlock(pre)); // 公式 container.querySelectorAll('[data-jio-math-source]').forEach((node) => { const formula = node.getAttribute('data-jio-math-source') || ''; const display = node.getAttribute('data-jio-math-display') !== '0'; const shortcode = `[jio_math display="${display ? '1' : '0'}"]${formula}[/jio_math]`; const paragraph = node.closest('p'); if (display && paragraph && paragraph.textContent?.trim() === node.textContent?.trim()) { paragraph.textContent = shortcode; } else { node.replaceWith(document.createTextNode(shortcode)); } }); // 注释 container.querySelectorAll ('[data-jio-footnote]').forEach((node) => { const note = node.getAttribute('data-jio-footnote') || ''; node.replaceWith(document.createTextNode(`[jio_footnote]${note}[/jio_footnote]`)); }); return container.innerHTML; } /** * 打开已有内容时:把短代码 / 前台的代码块结构还原成编辑器里的可视化块。 */ export function hydrateBlocks(html: string): string { if (!html) { return html; } const container = document.createElement('div'); container.innerHTML = html; container.querySelectorAll('pre').forEach((pre) => hydrateCodeBlock(pre)); return container.innerHTML .replace( /\[jio_math display="([01])"\]([\s\S]*?)\[\/jio_math\]/g, (_match, display: string, formula: string) => display === '0' ? `${typeset(formula)}` : ` ${typeset(formula)}
` ) .replace( /\[jio_footnote\]([\s\S]*?)\[\/jio_footnote\]/g, (_match, note: string) => `${note}` ); } const typeset = (raw: string): string => { const escaped = raw.replace(/&/g, '&').replace(//g, '>'); return escaped .replace(/\^\{([^{}]*)\}|\^(\S)/gu, '$1$2') .replace(/_\{([^{}]*)\}|_(\S)/gu, '$1$2'); };