diff --git a/libs/tiptap-editor/src/__tests__/code-block-view.test.ts b/libs/tiptap-editor/src/__tests__/code-block-view.test.ts index a8b9399..8e59d67 100644 --- a/libs/tiptap-editor/src/__tests__/code-block-view.test.ts +++ b/libs/tiptap-editor/src/__tests__/code-block-view.test.ts @@ -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 回调)', () => { diff --git a/libs/tiptap-editor/src/code-block-view.ts b/libs/tiptap-editor/src/code-block-view.ts index ecf55c8..b61f1b3 100644 --- a/libs/tiptap-editor/src/code-block-view.ts +++ b/libs/tiptap-editor/src/code-block-view.ts @@ -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()内的编辑(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; }