yggdrasil/libs/tiptap-editor/src/code-block-backspace-fix.ts
xfy 9c8ab791d9 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)。
2026-07-07 14:05:53 +08:00

62 lines
2.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
},
},
}),
];
},
});