fix(creator-studio): respect line breaks when deleting blocks

This commit is contained in:
kate522 committed 2026-11-10 19:48:40 +08:00
1 parent 561378312f
commit b497eb4a59
3 files changed
+99 -15

No files matched your search

+30 -8
View File
@@ -9,6 +9,7 @@
/** 按键删除时作为整体处理的正文顶层块。 */
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';
const NON_TEXT_CONTENT_SELECTOR = 'img,iframe,video,audio,table,pre,hr,figure,.jio-math,.jio-footnote-ref,.jio-file-link,.new-dplayer';
function isStructuralBlock(node: Element): boolean {
if (node.matches(STRUCTURAL_SELECTOR)) {
@@ -34,8 +35,20 @@ function isAtEdge(range: Range, container: Element, backward: boolean): boolean
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');
return !content.textContent?.length
&& !content.querySelector(`br:not([data-mce-bogus]),${NON_TEXT_CONTENT_SELECTOR}`);
}
function isBlankParagraph(node: Element): boolean {
return node.tagName === 'P' && !node.textContent?.replace(/\u00a0/g, ' ').trim()
&& !node.querySelector(NON_TEXT_CONTENT_SELECTOR);
}
function commitDeletion(editor: any, event: any, remove: () => void): void {
event.preventDefault();
editor.undoManager.transact(remove);
editor.nodeChanged();
editor.fire('change');
}
function topLevelBlock(node: Node, body: HTMLElement): Element | null {
@@ -71,12 +84,24 @@ export function removeAdjacentBlock(editor: any, event: any): void {
const current = topLevelBlock(range.startContainer, body);
let target: Element | null = null;
if (current) {
if (isBlankParagraph(current)) {
const adjacent = backward ? current.previousElementSibling : current.nextElementSibling;
if (adjacent && isStructuralBlock(adjacent) && isAtEdge(range, current, backward)) {
commitDeletion(editor, event, () => {
const offset = Array.prototype.indexOf.call(body.childNodes, current);
editor.dom.remove(current);
selection.setCursorLocation(body, offset);
});
}
return;
}
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)) {
const wrappedMedia = current.tagName === 'P' && isStructuralBlock(current);
if (!isAtEdge(range, edgeContainer, wrappedMedia ? !backward : backward)) {
return;
}
// 表格单元格和列表项的边界键由 TinyMCE 处理。
@@ -96,8 +121,7 @@ export function removeAdjacentBlock(editor: any, event: any): void {
if (!target || !isStructuralBlock(target)) {
return;
}
event.preventDefault();
editor.undoManager.transact(() => {
commitDeletion(editor, event, () => {
if (target === current) {
const previous = target.previousElementSibling;
const next = target.nextElementSibling;
@@ -120,15 +144,13 @@ export function removeAdjacentBlock(editor: any, event: any): void {
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);
body.insertBefore(paragraph, next);
selection.setCursorLocation(paragraph, 0);
} else {
selection.setRng(range);
}
}
});
editor.nodeChanged();
editor.fire('change');
}
/**
+17 -5
View File
@@ -154,14 +154,26 @@ export function hydrateBlocks(html: string): string {
container.innerHTML = html;
container.querySelectorAll('pre').forEach((pre) => hydrateCodeBlock(pre));
container.querySelectorAll('p').forEach((paragraph) => {
const match = paragraph.textContent?.trim().match(/^\[jio_math display="1"\]([\s\S]*?)\[\/jio_math\]$/);
if (!match) {
return;
}
const formula = match[1];
const span = document.createElement('span');
span.className = 'jio-math jio-math--block';
span.setAttribute('data-jio-math-source', formula);
span.setAttribute('data-jio-math-display', '1');
span.setAttribute('contenteditable', 'false');
span.innerHTML = typeset(formula);
paragraph.replaceChildren(span);
});
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>`
/\[jio_math display="0"\]([\s\S]*?)\[\/jio_math\]/g,
(_match, formula: string) =>
`<span class="jio-math" data-jio-math-source="${escapeAttr(formula)}" data-jio-math-display="0" contenteditable="false">${typeset(formula)}</span>`
)
.replace(
/\[jio_footnote\]([\s\S]*?)\[\/jio_footnote\]/g,
@@ -14,6 +14,12 @@ const compiled = ts.transpileModule(source, {
const runtime = {};
new Function('exports', compiled)(runtime);
const { removeAdjacentBlock } = runtime;
const serializeSource = fs.readFileSync(path.join(__dirname, '../src/utils/serialize.ts'), 'utf8');
const serializeCompiled = ts.transpileModule(serializeSource, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const serializer = {};
new Function('exports', serializeCompiled)(serializer);
function fixture(html, nodeSelector, offset = 0) {
const dom = new JSDOM(`<!doctype html><body contenteditable="true">${html}</body>`);
@@ -72,12 +78,56 @@ test('Delete removes a following list only at the end of ordinary text', () => {
});
test('a displayed formula is removed with its paragraph wrapper', () => {
const f = fixture('<p><span class="jio-math jio-math--block" contenteditable="false">x</span><br data-mce-bogus="1"></p><p>next</p>', 'p', 0);
const f = fixture('<p><span class="jio-math jio-math--block" contenteditable="false">x</span><br data-mce-bogus="1"></p><p>next</p>', 'p', 1);
assert.equal(f.press('Backspace').prevented, true);
assert.equal(f.body.innerHTML, '<p>next</p>');
assert.equal(f.editor.selection.getRng().startContainer, f.body.querySelector('p'));
});
test('Delete before a displayed formula removes only its paragraph', () => {
const f = fixture('<p>before</p><p><span class="jio-math jio-math--block" contenteditable="false">x</span></p>', 'p:nth-child(2)', 0);
assert.equal(f.press('Delete').prevented, true);
assert.equal(f.body.innerHTML, '<p>before</p>');
});
test('Backspace on the line after a formula removes the empty line first', () => {
const f = fixture('<p><span class="jio-math jio-math--block" contenteditable="false">x</span></p><p><br data-mce-bogus="1"></p>', 'p:nth-child(2)', 0);
assert.equal(f.press('Backspace').prevented, true);
assert.equal(f.body.querySelectorAll('.jio-math--block').length, 1);
assert.equal(f.body.children.length, 1);
assert.equal(f.editor.selection.getRng().startContainer, f.body);
assert.equal(f.press('Backspace').prevented, true);
assert.equal(f.body.querySelectorAll('.jio-math--block').length, 0);
assert.equal(f.body.firstElementChild.tagName, 'P');
});
test('Backspace on an empty line after a table preserves the table', () => {
const f = fixture('<table><tbody><tr><td>data</td></tr></tbody></table><p><br></p>', 'p', 0);
assert.equal(f.press('Backspace').prevented, true);
assert.equal(f.body.querySelectorAll('table').length, 1);
assert.equal(f.body.querySelectorAll('p').length, 0);
});
test('a saved display formula restores as one paragraph without phantom lines', () => {
const dom = new JSDOM('<!doctype html><body></body>');
const previousDocument = global.document;
global.document = dom.window.document;
try {
const saved = serializer.serializeBlocks('<p><span class="jio-math jio-math--block" data-jio-math-source="x^2" data-jio-math-display="1" contenteditable="false">x<sup>2</sup></span></p>');
dom.window.document.body.innerHTML = serializer.hydrateBlocks(saved);
assert.equal(dom.window.document.body.querySelectorAll('p').length, 1);
assert.equal(dom.window.document.body.querySelectorAll('.jio-math--block').length, 1);
} finally {
global.document = previousDocument;
}
});
test('a real line break inside a paragraph is not a block boundary', () => {
const f = fixture('<hr><p><br>text</p>', 'p', 1);
assert.equal(f.press('Backspace').prevented, false);
assert.equal(f.body.querySelectorAll('hr').length, 1);
});
test('nested quote and hidden-content editing boundaries remove the whole outer block', () => {
for (const html of [
'<div class="quote_q" contenteditable="false"><div contenteditable="true"><p>quote</p></div></div>',
@@ -119,7 +169,7 @@ test('body-level deletion places the caret before the next structural block', ()
});
test('media-only paragraphs are removed with their media', () => {
const f = fixture('<p><img src="example.jpg"><br data-mce-bogus="1"></p><p>next</p>', 'p', 0);
const f = fixture('<p><img src="example.jpg"><br data-mce-bogus="1"></p><p>next</p>', 'p', 1);
assert.equal(f.press('Backspace').prevented, true);
assert.equal(f.body.innerHTML, '<p>next</p>');
});