- placeholder: hide the body ::before hint once the editor body has focus, so clicking an empty document no longer leaves the hint sitting under the caret - code block: edit as a bare <pre> and convert to pre > code on save. TinyMCE treats pre > code as an inline boundary, so typing there leaked into the following paragraph; Enlighter also splits lines on newlines, so the <br> the editor produced rendered the whole block as one overflowing line - code block: align the iframe rendering with Enlighter (Menlo 13px/1.4, #f8f9fa, 8px radius, 12px 17px padding, nowrap) in both content CSS files - code block: move the caret into the block after insertion - hidden block: hang the block-delete overlay on documentElement instead of body. data-mce-bogus only skips serialization, so the extra body child made mceInsertContent wipe the document and drop the first hidden block - settings: give the toggle a real 44x24 hit area, align it to the top of the row, add aria-labels and show the disabled state - styles: drop the duplicated @use/@import of publish-drawer and block-tools - media insert/embed now serialize through serializeBlocks so code blocks are never stored in editor form
179 lines
5.5 KiB
TypeScript
179 lines
5.5 KiB
TypeScript
/**
|
||
* 编辑器内容 ↔ 保存格式的转换。
|
||
*
|
||
* 编辑器里把公式、注释渲染成可读的样子(方便写作时看到最终效果),
|
||
* 但落库必须回到短代码,服务端才能稳定渲染。隐藏内容本身就是
|
||
* 短代码 / 前台结构,不需要转换。
|
||
*
|
||
* 代码块例外:前台由 EnlighterJS 按「全局功能 → 代码高亮」统一高亮,认的是
|
||
* `pre > code`,并把源码里的换行当作分行;而 TinyMCE 把 `pre > code` 当成
|
||
* 行内边界,光标落不进去、输入会被拆成段落。因此编辑器里用裸 `<pre>` 编辑,
|
||
* 保存前套回 `pre > code` 并把 `<br>` 还原成换行符。
|
||
*
|
||
* @package Jio
|
||
*/
|
||
|
||
const escapeAttr = (value: string): string =>
|
||
value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||
|
||
const decodeEntities = (value: string): string => {
|
||
const area = document.createElement('textarea');
|
||
area.innerHTML = value;
|
||
return area.value;
|
||
};
|
||
|
||
/**
|
||
* 读出代码块的源码行。
|
||
*
|
||
* 两种形态都要认:编辑器里的裸 `<pre>`(换行是 `<br>`),以及前台落库的
|
||
* `pre > code`(换行是文本里的 `\n`)。`<code>` 只是容器,直接走进去读,
|
||
* 不重复计数。
|
||
*/
|
||
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 收尾的 <br>、落库时补的换行符)。
|
||
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
||
lines.pop();
|
||
}
|
||
return lines;
|
||
};
|
||
|
||
/**
|
||
* 编辑器里的裸 `<pre>` → 前台的 `pre > code`。
|
||
*
|
||
* `<br>` 与文本里的换行都换成分行符:Enlighter 直接按文本切行,留着 `<br>`
|
||
* 会把整块压成一行、长行溢出卡片。
|
||
*/
|
||
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` → 编辑器里可直接输入的裸 `<pre>`。
|
||
*/
|
||
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<HTMLElement>('[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<HTMLElement>('[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'
|
||
? `<span class="jio-math" data-jio-math-source="${escapeAttr(formula)}" data-jio-math-display="0" contenteditable="false">${typeset(formula)}</span>`
|
||
: `<p><span class="jio-math jio-math--block" data-jio-math-source="${escapeAttr(formula)}" data-jio-math-display="1" contenteditable="false">${typeset(formula)}</span></p>`
|
||
)
|
||
.replace(
|
||
/\[jio_footnote\]([\s\S]*?)\[\/jio_footnote\]/g,
|
||
(_match, note: string) =>
|
||
`<sup class="jio-footnote-ref" data-jio-footnote="${escapeAttr(decodeEntities(note))}" contenteditable="false">${note}</sup>`
|
||
);
|
||
}
|
||
|
||
const typeset = (raw: string): string => {
|
||
const escaped = raw.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
return escaped
|
||
.replace(/\^\{([^{}]*)\}|\^(\S)/gu, '<sup>$1$2</sup>')
|
||
.replace(/_\{([^{}]*)\}|_(\S)/gu, '<sub>$1$2</sub>');
|
||
};
|