块级删除浮层插在 TinyMCE 的 iframe 里,但隐藏规则只写在父页面样式表, iframe 是独立文档、拿不到这些样式,于是 opacity:0 / position:absolute 全部 失效,✕ 退化成正文顶部的常显元素。 - 把 #jio-block-actions 的定位与隐藏规则移入 content_style,并给 body 加 position:relative 作为定位基准;隐藏时 pointer-events:none 避免挡住正文 - 浮层加 data-mce-bogus="all",让 TinyMCE 在序列化、字数和选区计算时跳过它, 否则 ✕ 会被写进保存内容 - 删除 _block-tools.scss 中作用于同一选择器的页面级死样式 - 契约测试锁定「样式必须在 content_style」与 bogus 属性 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
221 lines
7.2 KiB
TypeScript
221 lines
7.2 KiB
TypeScript
/**
|
|
* TinyMCE 编辑器增强:块级删除、代码块语言角标、自适应高度。
|
|
*
|
|
* 这些行为都作用在编辑器 iframe 内部,与 React 状态无关,因此独立成模块。
|
|
*
|
|
* @package Jio
|
|
*/
|
|
|
|
import { CODE_LANGUAGES } from './utils/block-markup';
|
|
|
|
/** 可整体删除的块级元素选择器。 */
|
|
const BLOCK_SELECTOR = 'table,pre.enlighter-pre,hr,.quote_q,p';
|
|
|
|
/** 可整体删除的块级元素(排除普通段落)。 */
|
|
const STRUCTURAL_SELECTOR = 'table,pre,hr,.quote_q,.jio-math--block,.jio-toc-placeholder';
|
|
|
|
/**
|
|
* Backspace / Delete 删除相邻的块级元素。
|
|
*
|
|
* 表格、代码块、引用、分割线这类内容不是普通文本,TinyMCE 默认不会在
|
|
* 空段落里按删除键时主动移除它们,导致「删不掉」。这里在光标处于空段落、
|
|
* 且紧邻目标块时直接移除该块。
|
|
*/
|
|
export function removeAdjacentBlock(editor: any, event: any): void {
|
|
const key = Number(event.keyCode);
|
|
if (key !== 8 && key !== 46) {
|
|
return;
|
|
}
|
|
const selection = editor.selection;
|
|
if (!selection || !selection.isCollapsed()) {
|
|
return;
|
|
}
|
|
|
|
const body = editor.getBody();
|
|
const block = selection.getNode();
|
|
const isBlank = (node: any): boolean =>
|
|
!!node && node.nodeType === 1 && (node.textContent || '').trim() === '' &&
|
|
!editor.dom.getParent(node, STRUCTURAL_SELECTOR, body);
|
|
const isBlockElement = (node: any): boolean =>
|
|
!!node && node.nodeType === 1 && !!(node.matches?.(STRUCTURAL_SELECTOR));
|
|
|
|
// 光标可能落在空段落里,也可能落在块级元素的边界(TinyMCE 常见行为);
|
|
// 两种情况下都优先删除紧邻的块,而不是让按键悄悄失效。
|
|
const blankContainer = isBlank(block) ? block : null;
|
|
const direct = blankContainer
|
|
? (key === 8 ? blankContainer.previousElementSibling : blankContainer.nextElementSibling)
|
|
: null;
|
|
|
|
if (isBlockElement(direct)) {
|
|
event.preventDefault();
|
|
editor.dom.remove(direct);
|
|
editor.nodeChanged();
|
|
editor.undoManager.add();
|
|
return;
|
|
}
|
|
|
|
// 光标停在块首/块尾时,删除块本身。
|
|
const caretBlock = editor.dom.getParent(block, STRUCTURAL_SELECTOR, body);
|
|
if (!caretBlock) {
|
|
return;
|
|
}
|
|
const caretIsAtEdge = (start: boolean): boolean => {
|
|
const probe = editor.dom.createRng();
|
|
probe.selectNodeContents(caretBlock);
|
|
probe.collapse(start);
|
|
const caret = selection.getRng();
|
|
return caret && typeof caret.compareBoundaryPoints === 'function'
|
|
&& caret.compareBoundaryPoints(start ? 1 : 3, probe) === 0;
|
|
};
|
|
|
|
if (!((key === 8 && caretIsAtEdge(true)) || (key === 46 && caretIsAtEdge(false)))) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
const parent = caretBlock.parentElement;
|
|
editor.dom.remove(caretBlock);
|
|
// 删掉最后一个块后补一个空段落,否则没有可放置光标的位置。
|
|
if (parent && parent.children.length === 0) {
|
|
editor.setContent('<p></p>');
|
|
editor.selection.setCursorLocation(editor.getBody().firstChild, 0);
|
|
}
|
|
editor.nodeChanged();
|
|
editor.undoManager.add();
|
|
}
|
|
|
|
/**
|
|
* 让 iframe 高度跟随正文增长,避免编辑器内部出现第二根滚动条。
|
|
*
|
|
* TinyMCE 的 iframe 不会自动撑高;这里测量内容高度并同步容器,
|
|
* 页面整体滚动由外层工作台负责。
|
|
*/
|
|
export function syncEditorHeight(editor: any): void {
|
|
if (!editor || editor.removed) {
|
|
return;
|
|
}
|
|
const doc = editor.getDoc();
|
|
const iframe = editor.getContentAreaContainer()?.querySelector('iframe') as HTMLIFrameElement | null;
|
|
if (!doc || !iframe) {
|
|
return;
|
|
}
|
|
const height = Math.max(
|
|
320,
|
|
doc.documentElement.scrollHeight,
|
|
doc.body ? doc.body.scrollHeight : 0
|
|
);
|
|
iframe.style.height = `${height}px`;
|
|
iframe.style.overflow = 'hidden';
|
|
}
|
|
|
|
/**
|
|
* 在编辑器里为每个代码块渲染语言角标。
|
|
*
|
|
* 角标是编辑期提示(纯 UI),保存前由 serializeBlocks 移除。
|
|
*/
|
|
export function syncCodeLabels(editor: any): void {
|
|
const root: HTMLElement | null = editor?.getBody?.() ?? null;
|
|
if (!root) {
|
|
return;
|
|
}
|
|
root.querySelectorAll<HTMLElement>('pre.enlighter-pre[data-jio-code]').forEach((pre) => {
|
|
const existing = pre.querySelector(':scope > .jio-code-lang');
|
|
const language = pre.getAttribute('data-enlighter-language') || 'generic';
|
|
const label = CODE_LANGUAGES.find((item) => item.value === language)?.label || language;
|
|
if (existing) {
|
|
existing.setAttribute('data-jio-code-lang', language);
|
|
if ((existing.textContent || '') !== label) {
|
|
existing.textContent = label;
|
|
}
|
|
return;
|
|
}
|
|
const badge = document.createElement('span');
|
|
badge.className = 'jio-code-lang';
|
|
badge.contentEditable = 'false';
|
|
badge.setAttribute('data-jio-code-lang', language);
|
|
badge.textContent = label;
|
|
pre.insertBefore(badge, pre.firstChild);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 给表格 / 代码块 / 引用等块渲染一个浮层删除按钮。
|
|
*
|
|
* 与 WPS 的块操作一致:鼠标悬停到块上时在右上角出现删除入口,
|
|
* 避免只能靠反复按退格键。
|
|
*
|
|
* 浮层直接插在 iframe 的 body 里,因此用 data-mce-bogus 让 TinyMCE 在
|
|
* 序列化、字数和选区计算时完全忽略它;它的样式写在 editor-content-style.ts
|
|
* (父页面样式表管不到 iframe 内部)。
|
|
*/
|
|
export function installBlockActions(editor: any): void {
|
|
const doc = editor.getDoc();
|
|
if (!doc || doc.getElementById('jio-block-actions')) {
|
|
return;
|
|
}
|
|
const bar = doc.createElement('div');
|
|
bar.id = 'jio-block-actions';
|
|
bar.className = 'jio-creator__block-actions';
|
|
// "all" 让 TinyMCE 连同子节点一起跳过。
|
|
bar.setAttribute('data-mce-bogus', 'all');
|
|
bar.innerHTML =
|
|
'<button type="button" class="jio-creator__block-action" data-jio-block-delete title="' +
|
|
'删除此块' +
|
|
'" aria-label="删除此块"><span aria-hidden="true">✕</span></button>';
|
|
doc.body.appendChild(bar);
|
|
|
|
let current: HTMLElement | null = null;
|
|
|
|
const hide = () => {
|
|
current = null;
|
|
bar.classList.remove('is-visible');
|
|
};
|
|
|
|
const show = (block: HTMLElement) => {
|
|
if (!block || block === doc.body) {
|
|
hide();
|
|
return;
|
|
}
|
|
// 空段落没有删除价值,交给退格键处理。
|
|
if (block.tagName === 'P' && (block.textContent || '').trim() === '') {
|
|
hide();
|
|
return;
|
|
}
|
|
current = block;
|
|
const rect = block.getBoundingClientRect();
|
|
bar.style.top = `${rect.top + doc.documentElement.scrollTop + 6}px`;
|
|
bar.style.left = `${rect.right - 40}px`;
|
|
bar.classList.add('is-visible');
|
|
};
|
|
|
|
doc.addEventListener('mouseover', (event: MouseEvent) => {
|
|
const block = (event.target as HTMLElement)?.closest?.(BLOCK_SELECTOR) as HTMLElement | null;
|
|
if (block && block !== doc.body && !block.classList.contains('jio-code-lang')) {
|
|
show(block);
|
|
}
|
|
});
|
|
|
|
doc.addEventListener('mouseleave', hide);
|
|
|
|
bar.addEventListener('mousedown', (event: MouseEvent) => event.preventDefault());
|
|
bar.addEventListener('click', (event: MouseEvent) => {
|
|
const button = (event.target as HTMLElement)?.closest('[data-jio-block-delete]');
|
|
if (!button || !current) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
const target = current;
|
|
hide();
|
|
editor.undoManager.transact(() => {
|
|
editor.dom.remove(target);
|
|
});
|
|
editor.nodeChanged();
|
|
editor.undoManager.add();
|
|
if (editor.getBody().children.length === 0) {
|
|
editor.setContent('<p></p>');
|
|
editor.selection.setCursorLocation(editor.getBody().firstChild, 0);
|
|
}
|
|
syncEditorHeight(editor);
|
|
});
|
|
}
|