fix(creator-studio): repair structural block deletion

This commit is contained in:
kate522 committed 2026-11-10 19:36:43 +08:00
1 parent a061ec86ce
commit 561378312f
5 files changed
+256 -59

No files matched your search

+105 -53
View File
@@ -6,77 +6,129 @@
* @package Jio
*/
/** 结构块(不能从内部被拆开的块)。 */
const STRUCTURAL_SELECTOR = 'table,pre,hr,.quote_q,.tinymce-hide,.jio-math--block';
/** 按键删除时作为整体处理的正文顶层块。 */
const STRUCTURAL_SELECTOR = 'table,pre,hr,figure,ul,ol,.quote_q,.tinymce-hide,.new-dplayer,.wp-block-embed';
const WRAPPED_MEDIA_SELECTOR = '.jio-math--block,img,iframe,video,audio,.jio-file-link';
function isStructuralBlock(node: Element): boolean {
if (node.matches(STRUCTURAL_SELECTOR)) {
return true;
}
if (node.tagName !== 'P') {
return false;
}
const children = Array.from(node.childNodes).filter((child) =>
(child.nodeType !== 3 || !!child.textContent?.trim())
&& !(child.nodeType === 1 && (child as Element).matches('br[data-mce-bogus]'))
);
return children.length === 1 && children[0].nodeType === 1
&& (children[0] as Element).matches(WRAPPED_MEDIA_SELECTOR);
}
function isAtEdge(range: Range, container: Element, backward: boolean): boolean {
const remainder = range.cloneRange();
remainder.selectNodeContents(container);
if (backward) {
remainder.setEnd(range.startContainer, range.startOffset);
} else {
remainder.setStart(range.startContainer, range.startOffset);
}
const content = remainder.cloneContents();
return !content.textContent?.trim()
&& !content.querySelector('img,iframe,video,audio,table,pre,hr,figure,.jio-math,.jio-footnote-ref,.new-dplayer');
}
function topLevelBlock(node: Node, body: HTMLElement): Element | null {
let current = node.nodeType === 1 ? node as Element : node.parentElement;
while (current && current.parentElement !== body) {
current = current.parentElement;
}
return current?.parentElement === body ? current : null;
}
/**
* Backspace / Delete 删除相邻的块级元素。
*
* 表格、代码块、引用、分割线这类内容不是普通文本,TinyMCE 默认不会在
* 空段落里按删除键时主动移除它们,导致「删不掉」。这里在光标处于空段落、
* 且紧邻目标块时直接移除该块。
* 光标位于正文块边界时删除相邻的结构块;位于结构块自身的首尾时删除
* 整个块。选区中的文字和块内非边界位置交给 TinyMCE 正常处理。
*/
export function removeAdjacentBlock(editor: any, event: any): void {
const key = Number(event.keyCode);
if (key !== 8 && key !== 46) {
const key = event.key || (event.keyCode === 8 ? 'Backspace' : event.keyCode === 46 ? 'Delete' : '');
if ((key !== 'Backspace' && key !== 'Delete') || event.defaultPrevented || event.isComposing
|| event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
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();
const body = editor.getBody() as HTMLElement;
const range = selection.getRng() as Range;
if (!body || !range || !body.contains(range.startContainer)) {
return;
}
// 光标停在块首/块尾时,删除块本身。
const caretBlock = editor.dom.getParent(block, STRUCTURAL_SELECTOR, body);
if (!caretBlock) {
const backward = key === 'Backspace';
const current = topLevelBlock(range.startContainer, body);
let target: Element | null = null;
if (current) {
const node = range.startContainer.nodeType === 1
? range.startContainer as Element : range.startContainer.parentElement;
const nestedEditable = node?.closest('[contenteditable="true"]');
const edgeContainer = nestedEditable && current.contains(nestedEditable)
? nestedEditable : current;
if (!isAtEdge(range, edgeContainer, backward)) {
return;
}
// 表格单元格和列表项的边界键由 TinyMCE 处理。
if (current.matches('table,ul,ol')) {
return;
}
target = isStructuralBlock(current)
? current : backward ? current.previousElementSibling : current.nextElementSibling;
} else if (range.startContainer === body) {
let offset = range.startOffset + (backward ? -1 : 0);
const step = backward ? -1 : 1;
while (body.childNodes[offset]?.nodeType === 3 && !body.childNodes[offset].textContent?.trim()) {
offset += step;
}
target = body.childNodes[offset]?.nodeType === 1 ? body.childNodes[offset] as Element : null;
}
if (!target || !isStructuralBlock(target)) {
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.undoManager.transact(() => {
if (target === current) {
const previous = target.previousElementSibling;
const next = target.nextElementSibling;
const destination = backward
? [previous, next].find((item) => item && !isStructuralBlock(item))
: [next, previous].find((item) => item && !isStructuralBlock(item));
if (destination) {
editor.dom.remove(target);
selection.setCursorLocation(destination, destination === previous ? destination.childNodes.length : 0);
} else {
const paragraph = body.ownerDocument.createElement('p');
paragraph.appendChild(body.ownerDocument.createElement('br'));
body.insertBefore(paragraph, target);
editor.dom.remove(target);
selection.setCursorLocation(paragraph, 0);
}
} else {
const next = target.nextSibling;
editor.dom.remove(target);
if (!current && !Array.from(body.children).some((child) => !isStructuralBlock(child))) {
const paragraph = body.ownerDocument.createElement('p');
paragraph.appendChild(body.ownerDocument.createElement('br'));
body.insertBefore(paragraph, next);
selection.setCursorLocation(paragraph, 0);
} else {
selection.setRng(range);
}
}
});
editor.nodeChanged();
editor.undoManager.add();
editor.fire('change');
}
/**
@@ -404,11 +404,6 @@
}
}
/*
* 块级删除控件的样式在 editor-content-style.ts 里:该控件插在 TinyMCE
* iframe 内部,页面级样式表作用不到。这里不再重复定义。
*/
@media (max-width: 767px) {
.jio-creator__status {
gap: 8px;