fix(editor): appendTransaction 加重入防护 + Enter 行为测试

用户反馈:升级成 taskList 后,空项按 Enter 未退出列表(与斜杠命令创建的
行为不一致)。

单测用 splitListItem 命令验证:升级后的 taskList,有内容项 Enter 分裂出空
taskItem,空项 Enter 正确退出列表(doc 末尾产生 paragraph)。行为与斜杠
命令创建的一致。说明 appendTransaction 产生的节点结构合法。

追加防护:给 appendTransaction 返回的 transaction 打 pluginKey 标记,
重入时跳过,避免 Enter 等操作的文档变化被本插件二次干扰。

happy-dom 无法可靠模拟真实 keydown 链路(派发 KeyboardEvent 时光标映射
不准),Enter 的 bug 需真实浏览器复现。
This commit is contained in:
xfy 2026-07-02 14:59:53 +08:00
parent 873ccb1a80
commit 53b53d3169
2 changed files with 34 additions and 0 deletions

View File

@ -128,4 +128,31 @@ describe('TaskInputRule (appendTransaction 升级方案)', () => {
const block = firstBlock(editor) const block = firstBlock(editor)
expect(block.type).toBe('taskList') expect(block.type).toBe('taskList')
}) })
it('升级后 Enter 行为与斜杠命令创建的一致', async () => {
// 场景:升级成含内容的 taskList,Enter 新建空项,空项再 Enter 退出。
// 用 splitListItem 命令直接模拟(TaskItem 的 Enter 快捷键即绑定此命令)。
typeText(editor, '- ')
await flush()
typeText(editor, '[ ] 未完成')
await flush()
const taskList = firstBlock(editor)
expect(taskList.type).toBe('taskList')
expect(taskList.content?.[0]?.attrs?.checked).toBe(false)
// 第一次 Enter(splitListItem):有内容项 → 分裂出新的空 taskItem
editor.commands.splitListItem('taskItem')
const after1 = firstBlock(editor)
expect(after1.content?.length).toBe(2)
expect(after1.content?.[1]?.attrs?.checked).toBe(false)
// 第二次 Enter(splitListItem):空 taskItem(最后一项,段落为空)→ 应退出列表
editor.commands.splitListItem('taskItem')
// 退出后:taskList 保留原项,文档末尾新增普通段落
const json = editor.getJSON()
const lastType = json.content?.[json.content.length - 1]?.type
expect(lastType).toBe('paragraph')
})
}) })

View File

@ -44,6 +44,11 @@ export const TaskInputRule = Extension.create({
new Plugin({ new Plugin({
key: pluginKey, key: pluginKey,
appendTransaction: (transactions, _oldState, newState) => { appendTransaction: (transactions, _oldState, newState) => {
// 跳过由本插件产生的 transaction,避免重入(Enter 等操作产生的文档变化
// 不应再次触发升级判断,否则可能覆盖 splitListItem 的结果)。
const ownTr = transactions.some((tr) => tr.getMeta(pluginKey))
if (ownTr) return null
// 仅在文档实际变化时检查;非文档变化(选区移动)直接跳过。 // 仅在文档实际变化时检查;非文档变化(选区移动)直接跳过。
const docChanged = transactions.some((tr) => tr.docChanged) const docChanged = transactions.some((tr) => tr.docChanged)
if (!docChanged) return null if (!docChanged) return null
@ -124,6 +129,8 @@ export const TaskInputRule = Extension.create({
const paragraphTextLen = paragraph ? paragraph.content.size : 0 const paragraphTextLen = paragraph ? paragraph.content.size : 0
cursorPos += 1 + paragraphTextLen cursorPos += 1 + paragraphTextLen
replaceTr.setSelection(TextSelection.near(replaceTr.doc.resolve(cursorPos))) replaceTr.setSelection(TextSelection.near(replaceTr.doc.resolve(cursorPos)))
// 标记为本插件产生的 transaction,防止 appendTransaction 重入。
replaceTr.setMeta(pluginKey, { converted: true })
return replaceTr return replaceTr
}, },