From d391b0fd00ee4751a4dc8729cb748fd0e3a1e190 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 10 Nov 2026 20:01:16 +0800 Subject: [PATCH] fix(creator-studio): delete selected hidden blocks reliably --- assets/creator-studio/src/editor-runtime.ts | 49 ++++++++--- .../creator-studio/src/utils/block-markup.ts | 4 +- assets/creator-studio/src/utils/serialize.ts | 4 + .../tests/block-deletion.test.cjs | 88 +++++++++++++++++-- 4 files changed, 124 insertions(+), 21 deletions(-) diff --git a/assets/creator-studio/src/editor-runtime.ts b/assets/creator-studio/src/editor-runtime.ts index 5b2e972d..9ac615fc 100644 --- a/assets/creator-studio/src/editor-runtime.ts +++ b/assets/creator-studio/src/editor-runtime.ts @@ -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 diff --git a/assets/creator-studio/src/utils/block-markup.ts b/assets/creator-studio/src/utils/block-markup.ts index 8e3046ae..2ee6ee91 100644 --- a/assets/creator-studio/src/utils/block-markup.ts +++ b/assets/creator-studio/src/utils/block-markup.ts @@ -136,12 +136,12 @@ export const HIDE_BLOCKS: Record` + + `
` + `

${escapeHtml(def.open)}

` + `
${normalizeInner(innerHtml)}
` + `

${escapeHtml(def.close)}

` + diff --git a/assets/creator-studio/src/utils/serialize.ts b/assets/creator-studio/src/utils/serialize.ts index f1832f35..f2da662c 100644 --- a/assets/creator-studio/src/utils/serialize.ts +++ b/assets/creator-studio/src/utils/serialize.ts @@ -154,6 +154,10 @@ export function hydrateBlocks(html: string): string { container.innerHTML = html; container.querySelectorAll('pre').forEach((pre) => hydrateCodeBlock(pre)); + container.querySelectorAll('.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) { diff --git a/assets/creator-studio/tests/block-deletion.test.cjs b/assets/creator-studio/tests/block-deletion.test.cjs index 230a76c9..dee5e611 100644 --- a/assets/creator-studio/tests/block-deletion.test.cjs +++ b/assets/creator-studio/tests/block-deletion.test.cjs @@ -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(`${html}`); @@ -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 [ - '

quote

', - '

[vip]

secret

[/vip]

', - ]) { - const f = fixture(`${html}

next

`, '[contenteditable="true"] p', 0); - assert.equal(f.press('Backspace').prevented, true); - assert.equal(f.body.innerHTML, '

next

'); +test('a quote can be removed from its inner editing boundary', () => { + const f = fixture('

quote

next

', '[contenteditable="true"] p', 0); + assert.equal(f.press('Backspace').prevented, true); + assert.equal(f.body.innerHTML, '

next

'); +}); + +const hiddenBlock = '

[vip]

secret

[/vip]

'; + +test('new hidden blocks keep shortcodes outside the editable content', () => { + const dom = new JSDOM(''); + dom.window.document.body.innerHTML = markup.hiddenContentMarkup('vip', '

secret

'); + 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}


`, '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}

next

`, '.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, '

next

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