fix(creator-studio): repair structural block deletion
This commit is contained in:
1 parent
a061ec86ce
commit
561378312f
5 files changed
+260
-63
No files matched your search
Generated
+1
@@ -13,6 +13,7 @@
|
|||||||
"@wordpress/element": "5.8.0",
|
"@wordpress/element": "5.8.0",
|
||||||
"@wordpress/i18n": "4.31.0",
|
"@wordpress/i18n": "4.31.0",
|
||||||
"@wordpress/scripts": "26.4.0",
|
"@wordpress/scripts": "26.4.0",
|
||||||
|
"jsdom": "20.0.3",
|
||||||
"sass": "1.77.8",
|
"sass": "1.77.8",
|
||||||
"typescript": "5.4.5"
|
"typescript": "5.4.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "wp-scripts build",
|
"build": "wp-scripts build",
|
||||||
"start": "wp-scripts start"
|
"start": "wp-scripts start",
|
||||||
|
"test": "node --test tests/*.test.cjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "18.3.31",
|
"@types/react": "18.3.31",
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
"@wordpress/element": "5.8.0",
|
"@wordpress/element": "5.8.0",
|
||||||
"@wordpress/i18n": "4.31.0",
|
"@wordpress/i18n": "4.31.0",
|
||||||
"@wordpress/scripts": "26.4.0",
|
"@wordpress/scripts": "26.4.0",
|
||||||
|
"jsdom": "20.0.3",
|
||||||
"sass": "1.77.8",
|
"sass": "1.77.8",
|
||||||
"typescript": "5.4.5"
|
"typescript": "5.4.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,77 +6,129 @@
|
|||||||
* @package Jio
|
* @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 删除相邻的块级元素。
|
* Backspace / Delete 删除相邻的块级元素。
|
||||||
*
|
*
|
||||||
* 表格、代码块、引用、分割线这类内容不是普通文本,TinyMCE 默认不会在
|
* 光标位于正文块边界时删除相邻的结构块;位于结构块自身的首尾时删除
|
||||||
* 空段落里按删除键时主动移除它们,导致「删不掉」。这里在光标处于空段落、
|
* 整个块。选区中的文字和块内非边界位置交给 TinyMCE 正常处理。
|
||||||
* 且紧邻目标块时直接移除该块。
|
|
||||||
*/
|
*/
|
||||||
export function removeAdjacentBlock(editor: any, event: any): void {
|
export function removeAdjacentBlock(editor: any, event: any): void {
|
||||||
const key = Number(event.keyCode);
|
const key = event.key || (event.keyCode === 8 ? 'Backspace' : event.keyCode === 46 ? 'Delete' : '');
|
||||||
if (key !== 8 && key !== 46) {
|
if ((key !== 'Backspace' && key !== 'Delete') || event.defaultPrevented || event.isComposing
|
||||||
|
|| event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selection = editor.selection;
|
const selection = editor.selection;
|
||||||
if (!selection || !selection.isCollapsed()) {
|
if (!selection || !selection.isCollapsed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const body = editor.getBody() as HTMLElement;
|
||||||
const body = editor.getBody();
|
const range = selection.getRng() as Range;
|
||||||
const block = selection.getNode();
|
if (!body || !range || !body.contains(range.startContainer)) {
|
||||||
const isBlank = (node: any): boolean =>
|
return;
|
||||||
!!node && node.nodeType === 1 && (node.textContent || '').trim() === '' &&
|
}
|
||||||
!editor.dom.getParent(node, STRUCTURAL_SELECTOR, body);
|
const backward = key === 'Backspace';
|
||||||
const isBlockElement = (node: any): boolean =>
|
const current = topLevelBlock(range.startContainer, body);
|
||||||
!!node && node.nodeType === 1 && !!(node.matches?.(STRUCTURAL_SELECTOR));
|
let target: Element | null = null;
|
||||||
|
if (current) {
|
||||||
// 光标可能落在空段落里,也可能落在块级元素的边界(TinyMCE 常见行为);
|
const node = range.startContainer.nodeType === 1
|
||||||
// 两种情况下都优先删除紧邻的块,而不是让按键悄悄失效。
|
? range.startContainer as Element : range.startContainer.parentElement;
|
||||||
const blankContainer = isBlank(block) ? block : null;
|
const nestedEditable = node?.closest('[contenteditable="true"]');
|
||||||
const direct = blankContainer
|
const edgeContainer = nestedEditable && current.contains(nestedEditable)
|
||||||
? (key === 8 ? blankContainer.previousElementSibling : blankContainer.nextElementSibling)
|
? nestedEditable : current;
|
||||||
: null;
|
if (!isAtEdge(range, edgeContainer, backward)) {
|
||||||
|
return;
|
||||||
if (isBlockElement(direct)) {
|
}
|
||||||
|
// 表格单元格和列表项的边界键由 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;
|
||||||
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
editor.dom.remove(direct);
|
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.nodeChanged();
|
||||||
editor.undoManager.add();
|
editor.fire('change');
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -404,11 +404,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* 块级删除控件的样式在 editor-content-style.ts 里:该控件插在 TinyMCE
|
|
||||||
* iframe 内部,页面级样式表作用不到。这里不再重复定义。
|
|
||||||
*/
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.jio-creator__status {
|
.jio-creator__status {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const test = require('node:test');
|
||||||
|
const { JSDOM } = require('jsdom');
|
||||||
|
const ts = require('typescript');
|
||||||
|
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '../src/editor-runtime.ts'), 'utf8');
|
||||||
|
const compiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||||
|
}).outputText;
|
||||||
|
const runtime = {};
|
||||||
|
new Function('exports', compiled)(runtime);
|
||||||
|
const { removeAdjacentBlock } = runtime;
|
||||||
|
|
||||||
|
function fixture(html, nodeSelector, offset = 0) {
|
||||||
|
const dom = new JSDOM(`<!doctype html><body contenteditable="true">${html}</body>`);
|
||||||
|
const body = dom.window.document.body;
|
||||||
|
const node = nodeSelector === 'body' ? body : body.querySelector(nodeSelector);
|
||||||
|
let range = dom.window.document.createRange();
|
||||||
|
range.setStart(node, offset);
|
||||||
|
range.collapse(true);
|
||||||
|
const events = [];
|
||||||
|
const editor = {
|
||||||
|
getBody: () => body,
|
||||||
|
selection: {
|
||||||
|
isCollapsed: () => range.collapsed,
|
||||||
|
getRng: () => range,
|
||||||
|
setRng: (next) => { range = next; },
|
||||||
|
setCursorLocation: (target, at) => {
|
||||||
|
range = dom.window.document.createRange();
|
||||||
|
range.setStart(target, at);
|
||||||
|
range.collapse(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dom: { remove: (target) => target.remove() },
|
||||||
|
undoManager: { transact: (action) => { events.push('undo'); action(); } },
|
||||||
|
nodeChanged: () => events.push('node'),
|
||||||
|
fire: (name) => events.push(name),
|
||||||
|
};
|
||||||
|
const press = (key, options = {}) => {
|
||||||
|
const event = {
|
||||||
|
key,
|
||||||
|
prevented: false,
|
||||||
|
preventDefault() { this.prevented = true; },
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
removeAdjacentBlock(editor, event);
|
||||||
|
return event;
|
||||||
|
};
|
||||||
|
return { body, editor, events, press };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Backspace deletes the adjacent table from a paragraph boundary and records one edit', () => {
|
||||||
|
const f = fixture('<table><tbody><tr><td>data</td></tr></tbody></table><p><strong>after</strong></p>', 'strong', 0);
|
||||||
|
const event = f.press('Backspace');
|
||||||
|
assert.equal(event.prevented, true);
|
||||||
|
assert.equal(f.body.innerHTML, '<p><strong>after</strong></p>');
|
||||||
|
assert.deepEqual(f.events, ['undo', 'node', 'change']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Delete removes a following list only at the end of ordinary text', () => {
|
||||||
|
const f = fixture('<p>before</p><ul><li>item</li></ul>', 'p', 0);
|
||||||
|
f.editor.selection.setCursorLocation(f.body.querySelector('p').firstChild, 1);
|
||||||
|
assert.equal(f.press('Delete').prevented, false);
|
||||||
|
assert.equal(f.body.querySelectorAll('ul').length, 1);
|
||||||
|
f.editor.selection.setCursorLocation(f.body.querySelector('p').firstChild, 6);
|
||||||
|
assert.equal(f.press('Delete').prevented, true);
|
||||||
|
assert.equal(f.body.innerHTML, '<p>before</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
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('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>',
|
||||||
|
'<div class="tinymce-hide"><p class="hide-before">[vip]</p><div contenteditable="true"><p>secret</p></div><p class="hide-after">[/vip]</p></div>',
|
||||||
|
]) {
|
||||||
|
const f = fixture(`${html}<p>next</p>`, '[contenteditable="true"] p', 0);
|
||||||
|
assert.equal(f.press('Backspace').prevented, true);
|
||||||
|
assert.equal(f.body.innerHTML, '<p>next</p>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting the last structure creates an editable caret home without resetting content', () => {
|
||||||
|
const f = fixture('<pre>code</pre>', 'pre', 0);
|
||||||
|
assert.equal(f.press('Backspace').prevented, true);
|
||||||
|
assert.equal(f.body.children.length, 1);
|
||||||
|
assert.equal(f.body.firstElementChild.tagName, 'P');
|
||||||
|
assert.equal(f.editor.selection.getRng().startContainer, f.body.firstElementChild);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('body-level caret can delete an adjacent divider', () => {
|
||||||
|
const f = fixture('<hr><p>after</p>', 'body', 1);
|
||||||
|
assert.equal(f.press('Backspace').prevented, true);
|
||||||
|
assert.equal(f.body.innerHTML, '<p>after</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('body-level deletion of the only divider leaves a caret home', () => {
|
||||||
|
const f = fixture('<hr>', 'body', 1);
|
||||||
|
assert.equal(f.press('Backspace').prevented, true);
|
||||||
|
assert.equal(f.body.firstElementChild.tagName, 'P');
|
||||||
|
assert.equal(f.editor.selection.getRng().startContainer, f.body.firstElementChild);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('body-level deletion places the caret before the next structural block', () => {
|
||||||
|
const f = fixture('<hr><pre>code</pre>', 'body', 1);
|
||||||
|
assert.equal(f.press('Backspace').prevented, true);
|
||||||
|
assert.equal(f.body.firstElementChild.tagName, 'P');
|
||||||
|
assert.equal(f.body.lastElementChild.tagName, 'PRE');
|
||||||
|
assert.equal(f.editor.selection.getRng().startContainer, f.body.firstElementChild);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
assert.equal(f.press('Backspace').prevented, true);
|
||||||
|
assert.equal(f.body.innerHTML, '<p>next</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editing inside a table or list keeps the native key behavior', () => {
|
||||||
|
for (const html of [
|
||||||
|
'<table><tbody><tr><td>cell</td></tr></tbody></table>',
|
||||||
|
'<ul><li>item</li></ul>',
|
||||||
|
]) {
|
||||||
|
const f = fixture(html, html.startsWith('<table>') ? 'td' : 'li', 0);
|
||||||
|
assert.equal(f.press('Backspace').prevented, false);
|
||||||
|
assert.equal(f.body.firstElementChild.outerHTML, html);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selection and modified keys are left to native editing', () => {
|
||||||
|
const f = fixture('<hr><p>after</p>', 'p', 0);
|
||||||
|
assert.equal(f.press('Backspace', { ctrlKey: true }).prevented, false);
|
||||||
|
assert.equal(f.press('Backspace', { isComposing: true }).prevented, false);
|
||||||
|
const range = f.editor.selection.getRng();
|
||||||
|
range.selectNodeContents(f.body.querySelector('p'));
|
||||||
|
assert.equal(f.press('Backspace').prevented, false);
|
||||||
|
assert.equal(f.body.querySelectorAll('hr').length, 1);
|
||||||
|
assert.deepEqual(f.events, []);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user