fix(editor): 代码块内 Backspace 删整块(ignoreMutation 误忽略 contentDOM 编辑)

CodeBlockNodeView.ignoreMutation 无条件返回 true,导致 contentDOM(<code>)
内的 characterData/childList mutation 被忽略,ProseMirror 文档状态与 DOM
失同步——输入文字它不知道,Backspace 时基于错误状态重建,表现为删整块。

改为对齐 ProseMirror/Tiptap 默认 NodeView 逻辑:
- contentDOM 内的 mutation(characterData/childList)→ false(交给 PM)
- selection → false
- contentDOM 自身 attributes(高亮改 class)→ true(忽略)
- 工具栏/结果区装饰元素 → true(忽略)

补 2 个回归用例覆盖 contentDOM 内/外分支。参数类型改 ViewMutationRecord
(含 selection,ProseMirror 扩展类型)。
This commit is contained in:
xfy 2026-07-07 10:03:36 +08:00
parent c8b2248066
commit d8036c6e70
2 changed files with 58 additions and 4 deletions

View File

@ -1,3 +1,4 @@
import type { ViewMutationRecord } from '@tiptap/pm/view';
import { describe, expect, it, vi } from 'vitest';
import { CodeBlockNodeView } from '../code-block-view';
@ -97,12 +98,47 @@ describe('CodeBlockNodeView', () => {
expect(view.contentDOM?.classList.contains('runnable')).toBe(false);
});
it('ignoreMutation 返回 true(工具栏不触发编辑事务)', () => {
it('ignoreMutation: contentDOM 内的编辑 mutation 返回 false(让 ProseMirror 处理)', () => {
// 回归ignoreMutation 无条件返回 true 会导致 contentDOM 内的输入/退格被忽略,
// 文档状态与 DOM 失同步,表现为 Backspace 删整块而非删字符。
const view = new CodeBlockNodeView({
node: mockNode('python'),
editor: mockEditor(),
} as any);
expect(view.ignoreMutation()).toBe(true);
const code = view.contentDOM!;
// 模拟 code 内文本节点的 characterData mutation(输入/退格)
const textNode = document.createTextNode('fn main');
code.appendChild(textNode);
const editMutation = {
type: 'characterData',
target: textNode,
} as unknown as ViewMutationRecord;
expect(view.ignoreMutation(editMutation)).toBe(false);
// selection mutation 也应交给 ProseMirror
const selMutation = { type: 'selection', target: code } as unknown as ViewMutationRecord;
expect(view.ignoreMutation(selMutation)).toBe(false);
});
it('ignoreMutation: 工具栏/结果区装饰元素的 mutation 返回 true(忽略)', () => {
const view = new CodeBlockNodeView({
node: mockNode('python'),
editor: mockEditor(),
} as any);
// 工具栏(装饰元素)的 childList mutation 应忽略
const toolbarMutation = {
type: 'childList',
target: view.dom.querySelector('.tiptap-codeblock-toolbar')!,
addedNodes: [] as Node[],
removedNodes: [] as Node[],
} as unknown as ViewMutationRecord;
expect(view.ignoreMutation(toolbarMutation)).toBe(true);
// contentDOM 自身的 attributes 变化(如高亮改 class)忽略
const attrMutation = {
type: 'attributes',
target: view.contentDOM!,
attributeName: 'class',
} as unknown as ViewMutationRecord;
expect(view.ignoreMutation(attrMutation)).toBe(true);
});
it('点击运行按钮调用 onRunCode(storage 回调)', () => {

View File

@ -1,5 +1,6 @@
import type { Editor } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import type { ViewMutationRecord } from '@tiptap/pm/view';
import { extractLang, extractOverridesJson } from './highlight';
/** editor.storage 的 key宿主index.ts在此注入 onRunCode 回调。 */
@ -122,8 +123,25 @@ export class CodeBlockNodeView {
}
}
/** 工具栏/结果区交互不触发 ProseMirror 编辑事务。 */
ignoreMutation(): boolean {
/**
* DOM mutation ProseMirror
*
* contentDOM<code>characterData/childList false
* ProseMirror /退 DOM
* Backspace
*
* / true
* Tiptap NodeViewWrapper ignoreMutation
*/
ignoreMutation(mutation: ViewMutationRecord): boolean {
// selection让 ProseMirror 管光标
if (mutation.type === 'selection') return false;
// contentDOM 自身的 attributes 变化(如高亮 decoration 改 class忽略
if (mutation.target === this.contentDOM && mutation.type === 'attributes') return true;
// contentDOM 内的 mutationcharacterData/childList交给 ProseMirror编辑核心
// 注意 contains 对 target===contentDOM 自身也返回 true故上面的 attributes 判断须在前。
if (this.contentDOM && this.contentDOM.contains(mutation.target)) return false;
// 工具栏/结果区等装饰元素的 mutation忽略
return true;
}