fix(creator-studio): delete selected hidden blocks reliably

This commit is contained in:
kate522 committed 2026-11-10 20:01:16 +08:00
1 parent 304c59c8f3
commit d391b0fd00
4 files changed
+124 -21

No files matched your search

+38 -11
View File
@@ -59,11 +59,25 @@ function topLevelBlock(node: Node, body: HTMLElement): Element | null {
return current?.parentElement === body ? current : null;
}
function selectedTopLevelBlock(range: Range, body: HTMLElement): Element | null {
if (range.startContainer !== body || range.endContainer !== body || range.endOffset !== range.startOffset + 1) {
return null;
}
const node = body.childNodes[range.startOffset];
return node?.nodeType === 1 && isStructuralBlock(node as Element) ? node as Element : null;
}
function selectBlock(editor: any, event: any, block: Element): void {
event.preventDefault();
editor.selection.select(block);
editor.nodeChanged();
}
/**
* Backspace / Delete 删除相邻的块级元素。
*
* 光标位于正文块边界时删除相邻的结构块;位于结构块自身的首尾时删除
* 整个块。选区中的文字和块内非边界位置交给 TinyMCE 正常处理。
* 整个块。整块选区直接删除;文字选区和块内非边界位置交给 TinyMCE 处理。
*/
export function removeAdjacentBlock(editor: any, event: any): void {
const key = event.key || (event.keyCode === 8 ? 'Backspace' : event.keyCode === 46 ? 'Delete' : '');
@@ -72,7 +86,7 @@ export function removeAdjacentBlock(editor: any, event: any): void {
return;
}
const selection = editor.selection;
if (!selection || !selection.isCollapsed()) {
if (!selection) {
return;
}
const body = editor.getBody() as HTMLElement;
@@ -80,18 +94,27 @@ export function removeAdjacentBlock(editor: any, event: any): void {
if (!body || !range || !body.contains(range.startContainer)) {
return;
}
const collapsed = selection.isCollapsed();
const selected = collapsed ? null : selectedTopLevelBlock(range, body);
if (!collapsed && !selected) {
return;
}
const backward = key === 'Backspace';
const current = topLevelBlock(range.startContainer, body);
let target: Element | null = null;
const current = selected ? null : topLevelBlock(range.startContainer, body);
let target: Element | null = selected;
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);
});
if (adjacent.matches('.tinymce-hide')) {
selectBlock(editor, event, adjacent);
} else {
commitDeletion(editor, event, () => {
const offset = Array.prototype.indexOf.call(body.childNodes, current);
editor.dom.remove(current);
selection.setCursorLocation(body, offset);
});
}
}
return;
}
@@ -110,7 +133,7 @@ export function removeAdjacentBlock(editor: any, event: any): void {
}
target = isStructuralBlock(current)
? current : backward ? current.previousElementSibling : current.nextElementSibling;
} else if (range.startContainer === body) {
} else if (!selected && 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()) {
@@ -121,8 +144,12 @@ export function removeAdjacentBlock(editor: any, event: any): void {
if (!target || !isStructuralBlock(target)) {
return;
}
if (!selected && target.matches('.tinymce-hide')) {
selectBlock(editor, event, target);
return;
}
commitDeletion(editor, event, () => {
if (target === current) {
if (target === current || selected) {
const previous = target.previousElementSibling;
const next = target.nextElementSibling;
const destination = backward
@@ -136,12 +136,12 @@ export const HIDE_BLOCKS: Record<HideKind, { open: string; close: string; label:
*
* .hide-before 里的短代码文本通过 CSS 的 font-size:0 隐藏,只显示 data-jio-hide-label;
* .hide-after 与前台投稿编辑器一致地保持 display:none。两者都留在 DOM 里,保存时
* 不需要额外的序列化转换。
* 不需要额外的序列化转换。外层不可编辑,只有正文子容器可编辑。
*/
export function hiddenContentMarkup(kind: HideKind, innerHtml: string): string {
const def = HIDE_BLOCKS[kind];
return (
`<div class="tinymce-hide" data-jio-hide-type="${kind}">` +
`<div class="tinymce-hide" data-jio-hide-type="${kind}" contenteditable="false">` +
`<p class="hide-before" data-jio-hide-label="${escapeHtml(def.label)}">${escapeHtml(def.open)}</p>` +
`<div contenteditable="true">${normalizeInner(innerHtml)}</div>` +
`<p class="hide-after">${escapeHtml(def.close)}</p>` +
@@ -154,6 +154,10 @@ export function hydrateBlocks(html: string): string {
container.innerHTML = html;
container.querySelectorAll('pre').forEach((pre) => hydrateCodeBlock(pre));
container.querySelectorAll<HTMLElement>('.tinymce-hide').forEach((block) => {
block.setAttribute('contenteditable', 'false');
block.querySelector(':scope > div')?.setAttribute('contenteditable', 'true');
});
container.querySelectorAll('p').forEach((paragraph) => {
const match = paragraph.textContent?.trim().match(/^\[jio_math display="1"\]([\s\S]*?)\[\/jio_math\]$/);
if (!match) {
@@ -20,6 +20,12 @@ const serializeCompiled = ts.transpileModule(serializeSource, {
}).outputText;
const serializer = {};
new Function('exports', serializeCompiled)(serializer);
const markupSource = fs.readFileSync(path.join(__dirname, '../src/utils/block-markup.ts'), 'utf8');
const markupCompiled = ts.transpileModule(markupSource, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const markup = {};
new Function('exports', 'require', markupCompiled)(markup, () => ({ __: (value) => value }));
function fixture(html, nodeSelector, offset = 0) {
const dom = new JSDOM(`<!doctype html><body contenteditable="true">${html}</body>`);
@@ -40,6 +46,10 @@ function fixture(html, nodeSelector, offset = 0) {
range.setStart(target, at);
range.collapse(true);
},
select: (target) => {
range = dom.window.document.createRange();
range.selectNode(target);
},
},
dom: { remove: (target) => target.remove() },
undoManager: { transact: (action) => { events.push('undo'); action(); } },
@@ -128,14 +138,76 @@ test('a real line break inside a paragraph is not a block boundary', () => {
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>',
'<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('a quote can be removed from its inner editing boundary', () => {
const f = fixture('<div class="quote_q" contenteditable="false"><div contenteditable="true"><p>quote</p></div></div><p>next</p>', '[contenteditable="true"] p', 0);
assert.equal(f.press('Backspace').prevented, true);
assert.equal(f.body.innerHTML, '<p>next</p>');
});
const hiddenBlock = '<div class="tinymce-hide" contenteditable="false"><p class="hide-before">[vip]</p><div contenteditable="true"><p>secret</p></div><p class="hide-after">[/vip]</p></div>';
test('new hidden blocks keep shortcodes outside the editable content', () => {
const dom = new JSDOM('<!doctype html><body></body>');
dom.window.document.body.innerHTML = markup.hiddenContentMarkup('vip', '<p>secret</p>');
const block = dom.window.document.body.querySelector('.tinymce-hide');
assert.equal(block.getAttribute('contenteditable'), 'false');
assert.equal(block.querySelector(':scope > div').getAttribute('contenteditable'), 'true');
assert.equal(block.querySelector('.hide-before').textContent, '[vip]');
assert.equal(block.querySelector('.hide-after').textContent, '[/vip]');
});
test('Backspace after a hidden block selects it first, then removes it in one more press', () => {
const f = fixture(`${hiddenBlock}<p><br data-mce-bogus="1"></p>`, 'body > p', 0);
assert.equal(f.press('Backspace').prevented, true);
assert.equal(f.body.querySelectorAll('.tinymce-hide').length, 1);
assert.equal(f.editor.selection.getRng().startContainer, f.body);
assert.equal(f.editor.selection.getRng().endOffset, 1);
assert.equal(f.press('Backspace').prevented, true);
assert.equal(f.body.querySelectorAll('.tinymce-hide').length, 0);
assert.equal(f.body.firstElementChild.tagName, 'P');
assert.deepEqual(f.events, ['node', 'undo', 'node', 'change']);
});
test('editing hidden text stays native; Backspace at its start selects the whole block', () => {
const f = fixture(`${hiddenBlock}<p>next</p>`, '.tinymce-hide [contenteditable="true"] p', 0);
const text = f.body.querySelector('.tinymce-hide [contenteditable="true"] p').firstChild;
f.editor.selection.setCursorLocation(text, 3);
assert.equal(f.press('Backspace').prevented, false);
assert.equal(f.body.querySelector('.tinymce-hide [contenteditable="true"] p').textContent, 'secret');
f.editor.selection.setCursorLocation(text, 0);
assert.equal(f.press('Backspace').prevented, true);
assert.equal(f.editor.selection.getRng().endOffset, 1);
assert.equal(f.press('Backspace').prevented, true);
assert.equal(f.body.innerHTML, '<p>next</p>');
});
test('a manually selected hidden block deletes without repeated native key presses', () => {
const f = fixture(hiddenBlock, 'body', 0);
f.editor.selection.select(f.body.firstElementChild);
assert.equal(f.press('Delete').prevented, true);
assert.equal(f.body.querySelector('.tinymce-hide'), null);
assert.equal(f.body.firstElementChild.tagName, 'P');
});
test('selecting only hidden-block text does not delete its container', () => {
const f = fixture(hiddenBlock, '.tinymce-hide [contenteditable="true"] p', 0);
const range = f.editor.selection.getRng();
range.selectNodeContents(f.body.querySelector('.tinymce-hide [contenteditable="true"] p'));
assert.equal(f.press('Backspace').prevented, false);
assert.equal(f.body.querySelectorAll('.tinymce-hide').length, 1);
});
test('hydration restores the hidden block editing boundary', () => {
const dom = new JSDOM('<!doctype html><body></body>');
const previousDocument = global.document;
global.document = dom.window.document;
try {
dom.window.document.body.innerHTML = serializer.hydrateBlocks(hiddenBlock.replace(' contenteditable="false"', '').replace(' contenteditable="true"', ''));
const block = dom.window.document.body.querySelector('.tinymce-hide');
assert.equal(block.getAttribute('contenteditable'), 'false');
assert.equal(block.querySelector(':scope > div').getAttribute('contenteditable'), 'true');
} finally {
global.document = previousDocument;
}
});