/** * 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('

'); 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('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 = ''; 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('

'); editor.selection.setCursorLocation(editor.getBody().firstChild, 0); } syncEditorHeight(editor); }); }