From 250fe653c160d29ba1ae1de5ae1505cac665ff27 Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 2 Jul 2026 14:25:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(editor):=20=E4=BF=AE=E5=A4=8D=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=88=97=E8=A1=A8=E5=8D=87=E7=BA=A7=E5=90=8E=E5=85=89?= =?UTF-8?q?=E6=A0=87=E8=A2=AB=E7=94=A9=E5=88=B0=E4=B8=8B=E4=B8=80=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit appendTransaction 用 replaceWith 整段替换 bulletList→taskList,ProseMirror 的 选区映射无法把原 listItem 内的光标正确映射到新 taskItem 内,默认落到替换 区域之后(下一行)。 显式用 TextSelection.near 把光标设到命中的 taskItem 段落文本末尾: 按命中项在 newItems 中的索引 + 段落文本长度算出目标 pos。 新增光标位置断言(selection.from === 3,即 doc>taskList>taskItem>paragraph 内)。 --- .../src/__tests__/task-input-rule.test.ts | 3 +++ libs/tiptap-editor/src/task-input-rule.ts | 26 ++++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/libs/tiptap-editor/src/__tests__/task-input-rule.test.ts b/libs/tiptap-editor/src/__tests__/task-input-rule.test.ts index 1cb4ba7..ddbc6f3 100644 --- a/libs/tiptap-editor/src/__tests__/task-input-rule.test.ts +++ b/libs/tiptap-editor/src/__tests__/task-input-rule.test.ts @@ -79,6 +79,9 @@ describe('TaskInputRule (appendTransaction 升级方案)', () => { expect(taskItem?.attrs?.checked).toBe(false) // 前缀 "[ ] " 应被删除,不残留为文本 expect(taskItem?.content?.[0]?.content?.[0]?.text).not.toContain('[') + // 光标应落在命中 taskItem 的段落内(pos 3 = doc>taskList>taskItem>paragraph 内), + // 而非被甩到下一行(替换区域之后)。 + expect(editor.state.selection.from).toBe(3) }) it('打 "[x] " 升级成已勾选任务列表项', async () => { diff --git a/libs/tiptap-editor/src/task-input-rule.ts b/libs/tiptap-editor/src/task-input-rule.ts index 0c49964..59a7620 100644 --- a/libs/tiptap-editor/src/task-input-rule.ts +++ b/libs/tiptap-editor/src/task-input-rule.ts @@ -1,6 +1,6 @@ import { Extension } from '@tiptap/core' import type { Node, Fragment } from '@tiptap/pm/model' -import { Plugin, PluginKey } from '@tiptap/pm/state' +import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state' /** * 让手动输入 `- [ ]` / `- [x]` 创建任务列表。 @@ -85,8 +85,12 @@ export const TaskInputRule = Extension.create({ // 当前命中的 listItem 删除 `[ ] ` 前缀并设 checked;其余 listItem 默认未勾选。 const listPos = $from.start(listParentDepth) - 1 // bulletList 节点的文档位置 const newItems: Node[] = [] + // 记录命中项在 bulletList 中的序号(用于替换后定位光标)。 + let matchedIndex = -1 + let i = 0 listParent.forEach((itemNode) => { const isMatched = itemNode === listItem + if (isMatched) matchedIndex = i // 复用原 listItem 的子节点(段落等),命中的去掉前缀文本。 let children = itemNode.content if (isMatched) { @@ -98,13 +102,29 @@ export const TaskInputRule = Extension.create({ children, ), ) + i++ }) const newTaskList = taskListType.create(null, newItems) const replaceTr = tr.replaceWith(listPos, listPos + listParent.nodeSize, newTaskList) - // 保持光标在转换后的合理位置(命中的 taskItem 内,去掉前缀后)。 - // replaceWith 会保留映射,但为确保光标落在文本内,显式重设到新 taskItem 末尾。 + // 显式重设光标:整段 replaceWith 后,ProseMirror 的选区映射无法把原 listItem + // 内的光标正确映射到新 taskItem 内(默认落到替换区域之后=下一行)。 + // 这里算出命中的 taskItem 在新文档中的位置,把光标设到其段落文本末尾。 + const newMatchedItem = newTaskList.child(matchedIndex) + // taskItem 的第一个子节点是段落(textblock);光标落在段落内容末尾。 + const itemPos = listPos + 1 // taskList 起始后进入第一个 taskItem + let cursorPos = itemPos + for (let k = 0; k < matchedIndex; k++) { + cursorPos += newTaskList.child(k).nodeSize + } + // cursorPos 现在指向命中 taskItem 的起始;+1 进入其第一个段落, + // +段落文本长度 = 段落末尾(用户继续输入的位置)。 + const paragraph = newMatchedItem.firstChild + const paragraphTextLen = paragraph ? paragraph.content.size : 0 + cursorPos += 1 + paragraphTextLen + replaceTr.setSelection(TextSelection.near(replaceTr.doc.resolve(cursorPos))) + return replaceTr }, }),