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:
parent
c8b2248066
commit
d8036c6e70
@ -1,3 +1,4 @@
|
|||||||
|
import type { ViewMutationRecord } from '@tiptap/pm/view';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { CodeBlockNodeView } from '../code-block-view';
|
import { CodeBlockNodeView } from '../code-block-view';
|
||||||
|
|
||||||
@ -97,12 +98,47 @@ describe('CodeBlockNodeView', () => {
|
|||||||
expect(view.contentDOM?.classList.contains('runnable')).toBe(false);
|
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({
|
const view = new CodeBlockNodeView({
|
||||||
node: mockNode('python'),
|
node: mockNode('python'),
|
||||||
editor: mockEditor(),
|
editor: mockEditor(),
|
||||||
} as any);
|
} 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 回调)', () => {
|
it('点击运行按钮调用 onRunCode(storage 回调)', () => {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { Editor } from '@tiptap/core';
|
import type { Editor } from '@tiptap/core';
|
||||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||||
|
import type { ViewMutationRecord } from '@tiptap/pm/view';
|
||||||
import { extractLang, extractOverridesJson } from './highlight';
|
import { extractLang, extractOverridesJson } from './highlight';
|
||||||
|
|
||||||
/** editor.storage 的 key,宿主(index.ts)在此注入 onRunCode 回调。 */
|
/** 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 内的 mutation(characterData/childList):交给 ProseMirror(编辑核心)
|
||||||
|
// 注意 contains 对 target===contentDOM 自身也返回 true,故上面的 attributes 判断须在前。
|
||||||
|
if (this.contentDOM && this.contentDOM.contains(mutation.target)) return false;
|
||||||
|
// 工具栏/结果区等装饰元素的 mutation:忽略
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user