fix(editor): 修复升级后空项 Enter 不退出列表(根因:畸形文档)
bug 表现:- [ ] 123 升级成任务列表后,空项按 Enter 不退出,而是继续新建空项。 根因有两处,都在 appendTransaction 构造新节点时: 1. stripPrefix 作用域错误:原代码 cut 作用在 listItem.content(把段落节点 当原子),cut(N) 会切进段落的标签边界,产生畸形文档——段落文本前缀残留 (如' 未完成'多出空格)、nodeSize 与实际不符。修正为作用在段落内部的 content 上(用 para.type.create 重建段落),并用 Fragment.replaceChild 替换 listItem 的第一个子节点。 2. 光标位置少算一层:+1 进 taskList 内容,+offset 到命中 taskItem,+1 进 taskItem 内容(段落节点位置),还需 +1 进段落内容(文本),原代码漏了 这最后一个 +1,导致光标落在文本中间而非末尾,splitListItem 在错误位置 分裂(把'123'拆成'12'+'3')。 畸形文档 + 光标错位 → splitListItem 在错误位置分裂,产生的'空'taskItem 实际不空,二次 Enter 走分裂路径而非退出路径。 新增回归测试:段落文本无残留 + 完整 Enter 链路(升级→Enter 空项→Enter 退出)。
This commit is contained in:
parent
53b53d3169
commit
0772951867
@ -77,8 +77,9 @@ describe('TaskInputRule (appendTransaction 升级方案)', () => {
|
|||||||
const taskItem = block.content?.[0]
|
const taskItem = block.content?.[0]
|
||||||
expect(taskItem?.type).toBe('taskItem')
|
expect(taskItem?.type).toBe('taskItem')
|
||||||
expect(taskItem?.attrs?.checked).toBe(false)
|
expect(taskItem?.attrs?.checked).toBe(false)
|
||||||
// 前缀 "[ ] " 应被删除,不残留为文本
|
// 前缀 "[ ] " 应被删除,不残留为文本(空段落时 content 为空,也满足不含 [)
|
||||||
expect(taskItem?.content?.[0]?.content?.[0]?.text).not.toContain('[')
|
const paraText = taskItem?.content?.[0]?.textContent ?? ''
|
||||||
|
expect(paraText).not.toContain('[')
|
||||||
// 光标应落在命中 taskItem 的段落内(pos 3 = doc>taskList>taskItem>paragraph 内),
|
// 光标应落在命中 taskItem 的段落内(pos 3 = doc>taskList>taskItem>paragraph 内),
|
||||||
// 而非被甩到下一行(替换区域之后)。
|
// 而非被甩到下一行(替换区域之后)。
|
||||||
expect(editor.state.selection.from).toBe(3)
|
expect(editor.state.selection.from).toBe(3)
|
||||||
@ -129,28 +130,41 @@ describe('TaskInputRule (appendTransaction 升级方案)', () => {
|
|||||||
expect(block.type).toBe('taskList')
|
expect(block.type).toBe('taskList')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('升级后 Enter 行为与斜杠命令创建的一致', async () => {
|
it('升级后段落文本不含前缀残留(畸形文档会导致后续 Enter 异常)', async () => {
|
||||||
// 场景:升级成含内容的 taskList,Enter 新建空项,空项再 Enter 退出。
|
// 核心回归:stripPrefix 必须作用在段落 content 而非 listItem content,
|
||||||
// 用 splitListItem 命令直接模拟(TaskItem 的 Enter 快捷键即绑定此命令)。
|
// 否则 cut 会切进段落标签边界,产生畸形文档(文本前缀残留、nodeSize 异常)。
|
||||||
typeText(editor, '- ')
|
typeText(editor, '- ')
|
||||||
await flush()
|
await flush()
|
||||||
typeText(editor, '[ ] 未完成')
|
typeText(editor, '[ ] 未完成')
|
||||||
await flush()
|
await flush()
|
||||||
|
|
||||||
const taskList = firstBlock(editor)
|
const taskItem = firstBlock(editor).content?.[0]
|
||||||
expect(taskList.type).toBe('taskList')
|
const paraText = taskItem?.content?.[0]?.content?.[0]?.text
|
||||||
expect(taskList.content?.[0]?.attrs?.checked).toBe(false)
|
// 段落文本应严格为"未完成",无前导空格或残留方括号
|
||||||
|
expect(paraText).toBe('未完成')
|
||||||
|
})
|
||||||
|
|
||||||
// 第一次 Enter(splitListItem):有内容项 → 分裂出新的空 taskItem
|
it('升级后空项 Enter 退出列表(与斜杠命令行为一致)', async () => {
|
||||||
|
// 完整 Enter 链路:升级 → Enter 新建空项 → Enter 退出
|
||||||
|
typeText(editor, '- ')
|
||||||
|
await flush()
|
||||||
|
typeText(editor, '[ ] 123')
|
||||||
|
await flush()
|
||||||
|
expect(firstBlock(editor).type).toBe('taskList')
|
||||||
|
|
||||||
|
// 第一次 Enter:在内容项末尾分裂出空 taskItem
|
||||||
editor.commands.splitListItem('taskItem')
|
editor.commands.splitListItem('taskItem')
|
||||||
const after1 = firstBlock(editor)
|
const after1 = firstBlock(editor)
|
||||||
expect(after1.content?.length).toBe(2)
|
expect(after1.content?.length).toBe(2)
|
||||||
expect(after1.content?.[1]?.attrs?.checked).toBe(false)
|
// 第二项应为空 taskItem:段落无文本内容(getJSON 对空段落省略 content 数组)
|
||||||
|
const secondItem = after1.content?.[1]
|
||||||
|
const secondParaContent = secondItem?.content?.[0]?.content
|
||||||
|
expect(secondParaContent === undefined || secondParaContent?.length === 0).toBe(true)
|
||||||
|
|
||||||
// 第二次 Enter(splitListItem):空 taskItem(最后一项,段落为空)→ 应退出列表
|
// 第二次 Enter:空 taskItem(列表最后一项)→ splitListItem return false,
|
||||||
|
// 由 Enter 的 fallback(ProseMirror baseKeymap)退出列表,产生普通段落。
|
||||||
editor.commands.splitListItem('taskItem')
|
editor.commands.splitListItem('taskItem')
|
||||||
|
|
||||||
// 退出后:taskList 保留原项,文档末尾新增普通段落
|
|
||||||
const json = editor.getJSON()
|
const json = editor.getJSON()
|
||||||
const lastType = json.content?.[json.content.length - 1]?.type
|
const lastType = json.content?.[json.content.length - 1]?.type
|
||||||
expect(lastType).toBe('paragraph')
|
expect(lastType).toBe('paragraph')
|
||||||
|
|||||||
@ -96,10 +96,19 @@ export const TaskInputRule = Extension.create({
|
|||||||
listParent.forEach((itemNode) => {
|
listParent.forEach((itemNode) => {
|
||||||
const isMatched = itemNode === listItem
|
const isMatched = itemNode === listItem
|
||||||
if (isMatched) matchedIndex = i
|
if (isMatched) matchedIndex = i
|
||||||
// 复用原 listItem 的子节点(段落等),命中的去掉前缀文本。
|
// 复用原 listItem 的子节点(段落等);命中的去掉段落文本前缀。
|
||||||
|
// strip 必须作用在「段落内部的 content」上,而非 listItem 的 content:
|
||||||
|
// 后者把段落节点当成原子,cut(N) 会切进段落的标签边界,产生畸形文档,
|
||||||
|
// 导致后续 splitListItem 行为异常(空项 Enter 不退出)。
|
||||||
let children = itemNode.content
|
let children = itemNode.content
|
||||||
if (isMatched) {
|
if (isMatched) {
|
||||||
children = stripPrefix(children, match[0].length)
|
const para = itemNode.firstChild!
|
||||||
|
const stripped = para.type.create(
|
||||||
|
para.attrs,
|
||||||
|
stripPrefix(para.content, match[0].length),
|
||||||
|
)
|
||||||
|
// 用切除前缀后的段落替换第一个子节点;保留其余嵌套块(若有)。
|
||||||
|
children = itemNode.content.replaceChild(0, stripped)
|
||||||
}
|
}
|
||||||
newItems.push(
|
newItems.push(
|
||||||
taskItemType.create(
|
taskItemType.create(
|
||||||
@ -113,21 +122,21 @@ export const TaskInputRule = Extension.create({
|
|||||||
const newTaskList = taskListType.create(null, newItems)
|
const newTaskList = taskListType.create(null, newItems)
|
||||||
const replaceTr = tr.replaceWith(listPos, listPos + listParent.nodeSize, newTaskList)
|
const replaceTr = tr.replaceWith(listPos, listPos + listParent.nodeSize, newTaskList)
|
||||||
|
|
||||||
// 显式重设光标:整段 replaceWith 后,ProseMirror 的选区映射无法把原 listItem
|
// 显式重设光标:整段 replaceWith 后,选区映射会把光标甩到替换区域之后。
|
||||||
// 内的光标正确映射到新 taskItem 内(默认落到替换区域之后=下一行)。
|
// 基于新文档(replaceTr.doc)精确计算命中 taskItem 的段落末尾位置:
|
||||||
// 这里算出命中的 taskItem 在新文档中的位置,把光标设到其段落文本末尾。
|
// taskList 起始 listPos → 内容 listPos+1 → 偏移 matchedIndex 个 taskItem → +1 进段落 → +段落 size
|
||||||
const newMatchedItem = newTaskList.child(matchedIndex)
|
const resolvedList = replaceTr.doc.resolve(listPos + 1)
|
||||||
// taskItem 的第一个子节点是段落(textblock);光标落在段落内容末尾。
|
const taskListNode = resolvedList.node()
|
||||||
const itemPos = listPos + 1 // taskList 起始后进入第一个 taskItem
|
let offset = 0
|
||||||
let cursorPos = itemPos
|
for (let k = 0; k < matchedIndex && k < taskListNode.childCount; k++) {
|
||||||
for (let k = 0; k < matchedIndex; k++) {
|
offset += taskListNode.child(k).nodeSize
|
||||||
cursorPos += newTaskList.child(k).nodeSize
|
|
||||||
}
|
}
|
||||||
// cursorPos 现在指向命中 taskItem 的起始;+1 进入其第一个段落,
|
const hitItem = taskListNode.child(matchedIndex)
|
||||||
// +段落文本长度 = 段落末尾(用户继续输入的位置)。
|
const hitParaTextLen = hitItem.firstChild ? hitItem.firstChild.content.size : 0
|
||||||
const paragraph = newMatchedItem.firstChild
|
// 位置拆解:taskList 节点在 listPos;+1 进内容(第一个 taskItem 节点位置);
|
||||||
const paragraphTextLen = paragraph ? paragraph.content.size : 0
|
// +offset 到命中 taskItem 节点;+1 跨 taskItem 开标签进其内容(段落节点位置);
|
||||||
cursorPos += 1 + paragraphTextLen
|
// +1 跨段落开标签进文本;+文本长度到末尾。
|
||||||
|
const cursorPos = listPos + 1 + offset + 1 + 1 + hitParaTextLen
|
||||||
replaceTr.setSelection(TextSelection.near(replaceTr.doc.resolve(cursorPos)))
|
replaceTr.setSelection(TextSelection.near(replaceTr.doc.resolve(cursorPos)))
|
||||||
// 标记为本插件产生的 transaction,防止 appendTransaction 重入。
|
// 标记为本插件产生的 transaction,防止 appendTransaction 重入。
|
||||||
replaceTr.setMeta(pluginKey, { converted: true })
|
replaceTr.setMeta(pluginKey, { converted: true })
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user