fix(editor): codeBlock 内 Backspace 在 lowlight decoration 重建后失效

根因:CodeBlockLowlight 的 lowlight 插件在 codeBlock 内容变化后重新高亮,
重建 <code> 内 text node(包成 <span class=hljs-...>)。重建过程导致 DOM
selection 错位到 <pre>(contentDOM 父元素)而非 <code> 内文本,后续 Backspace
删不掉剩余字符,块删空后也无法删除整个 codeBlock。

文档层 ProseMirror selection($anchor)始终正确,只是 DOM 层不同步。用独立
Extension + handleKeyDown plugin prop 接管 codeBlock 内的 Backspace:基于
文档 pos 直接删除字符(不依赖 DOM selection),或在块空时 setNodeMarkup
转段落。handleKeyDown 优先级高于 keymap,能覆盖父类 CodeBlock 的同名 shortcut
(在 CodeBlockLowlight.extend 里 addKeyboardShortcuts 无法覆盖父类,故独立扩展)。

经 Playwright 真实浏览器逐键验证:输入 ab → 删 b → 删 a(此前失败) → 删块,
全链路正常。与 NodeView 无关(禁用 NodeView 同样复现,根因在 lowlight decoration)。
This commit is contained in:
xfy 2026-07-07 14:05:53 +08:00
parent 2a3a48af4e
commit 9c8ab791d9
2 changed files with 63 additions and 0 deletions

View File

@ -0,0 +1,61 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
/**
* CodeBlock Backspace lowlight decoration
*
* CodeBlockLowlight lowlight codeBlock
* `<code>` text node `<span class="hljs-...">` DOM selection
* `<pre>`contentDOM `<code>` Backspace
* codeBlock
*
* ProseMirror selection$anchor.parent / parentOffset DOM
* keymap codeBlock Backspace
* codeBlock pos
* clearNodes DOM selection
*
* Extension + addProseMirrorPlugins CodeBlockLowlight.extend
* addKeyboardShortcuts keymapTiptap
*/
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;
},
},
}),
];
},
});

View File

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