diff --git a/libs/tiptap-editor/src/code-block-backspace-fix.ts b/libs/tiptap-editor/src/code-block-backspace-fix.ts new file mode 100644 index 0000000..85340c5 --- /dev/null +++ b/libs/tiptap-editor/src/code-block-backspace-fix.ts @@ -0,0 +1,61 @@ +import { Extension } from '@tiptap/core'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; + +/** + * 修复 CodeBlock 内 Backspace 在 lowlight decoration 重建后失效的问题。 + * + * 背景:CodeBlockLowlight 的 lowlight 插件在 codeBlock 内容变化后重新高亮,重建 + * `` 内的 text node(包成 ``)。重建过程中 DOM selection + * 会错位到 `
`(contentDOM 的父元素)而非 `` 内文本节点,导致后续 Backspace
+ * 无效——删不掉剩余字符,块删空后也无法删除整个 codeBlock。
+ *
+ * 文档层 ProseMirror 的 selection($anchor.parent / parentOffset)始终正确,只是 DOM
+ * 层与文档层不同步。这里用一个高优先级 keymap 插件接管 codeBlock 内的 Backspace:
+ * 当光标在 codeBlock 内(文档层判断)时,直接基于文档 pos 删除前一个字符,或在块空时
+ * clearNodes 转段落,绕过 DOM selection 错位。
+ *
+ * 必须用独立 Extension + addProseMirrorPlugins(而非在 CodeBlockLowlight.extend 里
+ * addKeyboardShortcuts):后者无法覆盖父类已注册的同名 keymap(Tiptap 优先级限制)。
+ */
+const pluginKey = new PluginKey('codeBlockBackspaceFix');
+
+export const CodeBlockBackspaceFix = Extension.create({
+  name: 'codeBlockBackspaceFix',
+
+  addProseMirrorPlugins() {
+    const codeBlockName = 'codeBlock';
+    return [
+      // 高优先级(数字越小越先),抢在父类 CodeBlock keymap 之前
+      new Plugin({
+        key: pluginKey,
+        props: {
+          handleKeyDown(view, event) {
+            if (event.key !== 'Backspace') return false;
+            const { empty, $anchor } = view.state.selection;
+            if (!empty) return false;
+            // 仅处理光标在 codeBlock 内的情况
+            if ($anchor.parent.type.name !== codeBlockName) return false;
+
+            // 在块起始位置(parentOffset 0)
+            if ($anchor.parentOffset === 0) {
+              // 块为空 → 转段落(等价 clearNodes)
+              if ($anchor.parent.textContent.length === 0) {
+                const tr = view.state.tr;
+                const defaultType = view.state.schema.nodes.paragraph;
+                tr.setNodeMarkup($anchor.before(), defaultType);
+                view.dispatch(tr);
+                return true;
+              }
+              return false; // 块非空但在起始,不处理(让光标留在块内)
+            }
+            // 删除前一个字符(基于文档 pos,不依赖 DOM selection)
+            const tr = view.state.tr;
+            tr.delete($anchor.pos - 1, $anchor.pos);
+            view.dispatch(tr);
+            return true;
+          },
+        },
+      }),
+    ];
+  },
+});
diff --git a/libs/tiptap-editor/src/index.ts b/libs/tiptap-editor/src/index.ts
index 8d87df6..010588d 100644
--- a/libs/tiptap-editor/src/index.ts
+++ b/libs/tiptap-editor/src/index.ts
@@ -5,6 +5,7 @@ import { TaskItem, TaskList } from '@tiptap/extension-list';
 import { TableKit } from '@tiptap/extension-table';
 import { Markdown } from '@tiptap/markdown';
 import StarterKit from '@tiptap/starter-kit';
+import { CodeBlockBackspaceFix } from './code-block-backspace-fix';
 import { CodeBlockNodeView, ON_RUN_CODE_STORAGE_KEY } from './code-block-view';
 import { lowlight } from './highlight';
 import { SlashCommand } from './slash-command';
@@ -116,6 +117,7 @@ class TiptapEditorInstance {
             return ({ node, editor }) => new CodeBlockNodeView({ node, editor });
           },
         }),
+        CodeBlockBackspaceFix,
         TableKit,
         UploadImage,
         TaskList,