diff --git a/assets/creator-studio/package-lock.json b/assets/creator-studio/package-lock.json index e99d4d2a..3dedc485 100644 --- a/assets/creator-studio/package-lock.json +++ b/assets/creator-studio/package-lock.json @@ -13,6 +13,7 @@ "@wordpress/element": "5.8.0", "@wordpress/i18n": "4.31.0", "@wordpress/scripts": "26.4.0", + "jsdom": "20.0.3", "sass": "1.77.8", "typescript": "5.4.5" } diff --git a/assets/creator-studio/package.json b/assets/creator-studio/package.json index 9e8d9dfb..c844508a 100644 --- a/assets/creator-studio/package.json +++ b/assets/creator-studio/package.json @@ -4,7 +4,8 @@ "private": true, "scripts": { "build": "wp-scripts build", - "start": "wp-scripts start" + "start": "wp-scripts start", + "test": "node --test tests/*.test.cjs" }, "devDependencies": { "@types/react": "18.3.31", @@ -12,6 +13,7 @@ "@wordpress/element": "5.8.0", "@wordpress/i18n": "4.31.0", "@wordpress/scripts": "26.4.0", + "jsdom": "20.0.3", "sass": "1.77.8", "typescript": "5.4.5" } diff --git a/assets/creator-studio/src/editor-runtime.ts b/assets/creator-studio/src/editor-runtime.ts index 26156d09..040fb661 100644 --- a/assets/creator-studio/src/editor-runtime.ts +++ b/assets/creator-studio/src/editor-runtime.ts @@ -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('
'); - 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'); } /** diff --git a/assets/creator-studio/src/styles/_block-tools.scss b/assets/creator-studio/src/styles/_block-tools.scss index a6555e1f..aeb77253 100644 --- a/assets/creator-studio/src/styles/_block-tools.scss +++ b/assets/creator-studio/src/styles/_block-tools.scss @@ -404,11 +404,6 @@ } } -/* - * 块级删除控件的样式在 editor-content-style.ts 里:该控件插在 TinyMCE - * iframe 内部,页面级样式表作用不到。这里不再重复定义。 - */ - @media (max-width: 767px) { .jio-creator__status { gap: 8px; diff --git a/assets/creator-studio/tests/block-deletion.test.cjs b/assets/creator-studio/tests/block-deletion.test.cjs new file mode 100644 index 00000000..43c6492d --- /dev/null +++ b/assets/creator-studio/tests/block-deletion.test.cjs @@ -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(`${html}`); + 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('| data |
after
', 'strong', 0); + const event = f.press('Backspace'); + assert.equal(event.prevented, true); + assert.equal(f.body.innerHTML, 'after
'); + assert.deepEqual(f.events, ['undo', 'node', 'change']); +}); + +test('Delete removes a following list only at the end of ordinary text', () => { + const f = fixture('before
before
'); +}); + +test('a displayed formula is removed with its paragraph wrapper', () => { + const f = fixture('x
next
', 'p', 0); + assert.equal(f.press('Backspace').prevented, true); + assert.equal(f.body.innerHTML, 'next
'); + 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 [ + 'quote
secret
next
`, '[contenteditable="true"] p', 0); + assert.equal(f.press('Backspace').prevented, true); + assert.equal(f.body.innerHTML, 'next
'); + } +}); + +test('deleting the last structure creates an editable caret home without resetting content', () => { + const f = fixture('code', '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('
after
', 'body', 1); + assert.equal(f.press('Backspace').prevented, true); + assert.equal(f.body.innerHTML, 'after
'); +}); + +test('body-level deletion of the only divider leaves a caret home', () => { + const f = fixture('code', '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('

next
', 'p', 0); + assert.equal(f.press('Backspace').prevented, true); + assert.equal(f.body.innerHTML, 'next
'); +}); + +test('editing inside a table or list keeps the native key behavior', () => { + for (const html of [ + '| cell |