diff --git a/assets/creator-studio/src/editor-runtime.ts b/assets/creator-studio/src/editor-runtime.ts index 040fb661..5b2e972d 100644 --- a/assets/creator-studio/src/editor-runtime.ts +++ b/assets/creator-studio/src/editor-runtime.ts @@ -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'); } /** diff --git a/assets/creator-studio/src/utils/serialize.ts b/assets/creator-studio/src/utils/serialize.ts index 25d4d2a9..f1832f35 100644 --- a/assets/creator-studio/src/utils/serialize.ts +++ b/assets/creator-studio/src/utils/serialize.ts @@ -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' - ? `${typeset(formula)}` - : `

${typeset(formula)}

` + /\[jio_math display="0"\]([\s\S]*?)\[\/jio_math\]/g, + (_match, formula: string) => + `${typeset(formula)}` ) .replace( /\[jio_footnote\]([\s\S]*?)\[\/jio_footnote\]/g, diff --git a/assets/creator-studio/tests/block-deletion.test.cjs b/assets/creator-studio/tests/block-deletion.test.cjs index 43c6492d..230a76c9 100644 --- a/assets/creator-studio/tests/block-deletion.test.cjs +++ b/assets/creator-studio/tests/block-deletion.test.cjs @@ -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(`${html}`); @@ -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('

x

next

', 'p', 0); + const f = fixture('

x

next

', 'p', 1); 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('Delete before a displayed formula removes only its paragraph', () => { + const f = fixture('

before

x

', 'p:nth-child(2)', 0); + assert.equal(f.press('Delete').prevented, true); + assert.equal(f.body.innerHTML, '

before

'); +}); + +test('Backspace on the line after a formula removes the empty line first', () => { + const f = fixture('

x


', '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('
data


', '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(''); + const previousDocument = global.document; + global.document = dom.window.document; + try { + const saved = serializer.serializeBlocks('

x2

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


text

', '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 [ '

quote

', @@ -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('


next

', 'p', 0); + const f = fixture('


next

', 'p', 1); assert.equal(f.press('Backspace').prevented, true); assert.equal(f.body.innerHTML, '

next

'); });