fix(editor): TaskInputRule 改用 appendTransaction(逐字符输入才生效)

input rule 方案对逐字符输入无效:BulletList 在用户打 - (第2字符)时就抢先
触发,把行变成 bulletList>listItem,之后 [ ] 在 listItem 内无法被任何
input rule 升级。提高 priority 也无济于事——BulletList 在更短文本上抢先。

改用 appendTransaction 监听文档变化:BulletList 触发后,用户在 listItem 里
打出 [ ] / [x] 前缀时,把该 bulletList 整体替换成 taskList,对应 listItem
转成带 checked 的 taskItem 并删除已识别的文本前缀。

- 测试模拟逐字符时序:先 - (applyInputRules 触发 BulletList),再 [ ]
  (appendTransaction 升级),5 个用例覆盖未勾选/已勾选/大写/不误判/星号
- * + - 三种 marker 的 bulletList 都会升级(BulletList 对三者创建同种节点)
This commit is contained in:
xfy 2026-07-02 14:16:34 +08:00
parent 1f8147f323
commit 4a27236859
2 changed files with 175 additions and 81 deletions

View File

@ -6,31 +6,24 @@ import { TaskList, TaskItem } from '@tiptap/extension-list'
import { TaskInputRule } from '../task-input-rule' import { TaskInputRule } from '../task-input-rule'
/** /**
* TaskInputRule happy-dom DOM + Editor * TaskInputRule (happy-dom DOM + Editor)
* *
* input rule ProseMirror handleTextInput * 真实输入是逐字符的: `- ` BulletList input rule ,
* `insertContentAt(pos, text, { applyInputRules: true })` * bulletList > listItem; `[ ] ` appendTransaction listItem
* inputRulesPlugin applyInputRules metasetTimeout * , bulletList taskList
* await setTimeout
* *
* 46 upload-coordinator/upload-image/slash-command * insertContentAt( applyInputRules)模拟逐段输入:它走正常 dispatch,
* TaskInputRule /checked/ * input rule( handleTextInput plugin) appendTransaction
* ,分两步: `- `( BulletList), `[ ] `()
*
* 46 upload-coordinator/upload-image/slash-command
*/ */
/** 等待 applyInputRules 的 setTimeout 排空。 */ /** 等待异步(input rule 的 setTimeout + appendTransaction)。 */
function flushInputRules() { function flush() {
return new Promise((resolve) => setTimeout(resolve, 0)) return new Promise((resolve) => setTimeout(resolve, 0))
} }
/**
* JSON (/)
* getJSON() content Node | Text ,Text content/attrs,
* any ()
*/
function firstBlock(editor: Editor): any {
return editor.getJSON().content?.[0]
}
function makeEditor() { function makeEditor() {
return new Editor({ return new Editor({
element: document.body, element: document.body,
@ -44,12 +37,24 @@ function makeEditor() {
}) })
} }
/** 在文档开头(段落内 pos 1插入文本并触发 input rule。 */ /**
* ,
* marker( "- " "* "), applyInputRules BulletList input rule;
* appendTransaction
*/
function typeText(editor: Editor, text: string) { function typeText(editor: Editor, text: string) {
editor.commands.insertContentAt(1, text, { applyInputRules: true }) const isListMarker = /^\s*[-+*]\s$/.test(text)
editor.commands.insertContentAt(editor.state.selection.from, text, {
applyInputRules: isListMarker,
})
} }
describe('TaskInputRule', () => { /** 取 JSON 文档首个块节点。getJSON content 是 Node|Text 联合,断言 any 窄化。 */
function firstBlock(editor: Editor): any {
return editor.getJSON().content?.[0]
}
describe('TaskInputRule (appendTransaction 升级方案)', () => {
let editor: Editor let editor: Editor
beforeEach(() => { beforeEach(() => {
@ -57,59 +62,67 @@ describe('TaskInputRule', () => {
editor = makeEditor() editor = makeEditor()
}) })
it('打 "- [ ] " 时创建未勾选任务列表项', async () => { it('打 "- " 再打 "[ ] " 后升级成未勾选任务列表', async () => {
typeText(editor, '- [ ] ') // 步骤 1:打 "- " → BulletList input rule 触发
await flushInputRules() typeText(editor, '- ')
await flush()
expect(firstBlock(editor).type).toBe('bulletList')
// 步骤 2:在 listItem 内打 "[ ] " → appendTransaction 升级成 taskList
typeText(editor, '[ ] ')
await flush()
const block = firstBlock(editor) const block = firstBlock(editor)
expect(block.type).toBe('taskList') expect(block.type).toBe('taskList')
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)
// 前缀 "[ ] " 应被删除,不残留为文本
expect(taskItem?.content?.[0]?.content?.[0]?.text).not.toContain('[')
}) })
it('打 "- [x] " 时创建已勾选任务列表项', async () => { it('打 "[x] " 升级成已勾选任务列表项', async () => {
typeText(editor, '- [x] ') typeText(editor, '- ')
await flushInputRules() await flush()
typeText(editor, '[x] ')
await flush()
const block = firstBlock(editor) const block = firstBlock(editor)
expect(block.type).toBe('taskList') expect(block.type).toBe('taskList')
const taskItem = block.content?.[0] expect(block.content?.[0]?.attrs?.checked).toBe(true)
expect(taskItem?.type).toBe('taskItem')
expect(taskItem?.attrs?.checked).toBe(true)
}) })
it('打 "- [X] "(大写 X也算已勾选', async () => { it('打 "[X] "(大写)也算已勾选', async () => {
typeText(editor, '- [X] ')
await flushInputRules()
const block = firstBlock(editor)
const taskItem = block.content?.[0]
expect(taskItem?.type).toBe('taskItem')
expect(taskItem?.attrs?.checked).toBe(true)
})
it('打 "- 文本" 时是普通无序列表(不误判为任务)', async () => {
// 模拟逐段输入:先 "- " 触发 BulletList,再补 "文本"(真实用户是逐字符)
typeText(editor, '- ') typeText(editor, '- ')
await flushInputRules() await flush()
typeText(editor, '文本') typeText(editor, '[X] ')
await flushInputRules() await flush()
const block = firstBlock(editor) const block = firstBlock(editor)
expect(block.type).toBe('bulletList') expect(block.type).toBe('taskList')
expect(block.content?.[0].type).toBe('listItem') expect(block.content?.[0]?.attrs?.checked).toBe(true)
}) })
it('打 "* [ ] " 时是普通无序列表(星号 marker 不识别成任务)', async () => { it('打普通文本不升级(保持 bulletList)', async () => {
// 用户选定仅识别减号 marker,* 仍走 BulletList typeText(editor, '- ')
typeText(editor, '* ') await flush()
await flushInputRules() typeText(editor, '普通项')
typeText(editor, '[ ] ') await flush()
await flushInputRules()
const block = firstBlock(editor) const block = firstBlock(editor)
expect(block.type).toBe('bulletList') expect(block.type).toBe('bulletList')
expect(block.content?.[0].type).toBe('listItem') expect(block.content?.[0]?.type).toBe('listItem')
})
it('星号 marker 列表打 "[ ] " 也升级(* 触发的是 bulletList)', async () => {
// BulletList 对 * + - 三种 marker 都创建 bulletList 节点,
// appendTransaction 识别到 listItem 内的 [ ] 前缀即升级,与 marker 无关。
typeText(editor, '* ')
await flush()
typeText(editor, '[ ] ')
await flush()
const block = firstBlock(editor)
expect(block.type).toBe('taskList')
}) })
}) })

View File

@ -1,42 +1,123 @@
import { Extension, wrappingInputRule } from '@tiptap/core' import { Extension } from '@tiptap/core'
import type { Node, Fragment } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
/** /**
* `- [ ]` / `- [x]`( marker + + + ) * `- [ ]` / `- [x]`
* *
* 为什么需要它:Tiptap TaskItem input rule `[ ] `( marker), * input rule:StarterKit BulletList input rule `/^\s*([-+*])\s$/`
* StarterKit BulletList input rule `/^\s*([-+*])\s$/` `- ` , * `- `( 2 ), bulletList > listItem;
* ; `[ ]` ,getMarkdown `\[ \]` * `[ ]`, listItem , input rule listItem
* priority BulletList , `- [ ] ` * taskItem(input rule ,)
* priority BulletList (2 ),
* 5
* *
* marker( GitHub/Typora );`*` `+` BulletList * 解法: appendTransaction BulletList , listItem
* `[ ] ` / `[x] ` , bulletList taskList, listItem
* checked taskItem, `[ ] `
*
* marker bulletList(),Enter TaskItem
* splitListItem,
*/ */
const taskInputRegex = /^(\s*-\s)\[( |x|X)\]\s$/
/** const pluginKey = new PluginKey('taskListAutoConvert')
* `- [ ]` / `- [x]`
* /** 匹配 listItem 内刚打出的 `[ ] ` / `[x] ` / `[X] ` 前缀。 */
* `priority: 1000` BulletList( priority 100) input rule const taskPrefixRegex = /^\[([ xX])\]\s/
* `wrappingInputRule` taskItem ,ProseMirror findWrapping
* `paragraph → taskList > taskItem` (taskItem schema taskList )
*
* Enter TaskItem splitListItem( checked=false, Enter 退),
*
*/
export const TaskInputRule = Extension.create({ export const TaskInputRule = Extension.create({
name: 'taskInputRule', name: 'taskInputRule',
// 高于 BulletList 默认的 100,确保本扩展的 input rule 先匹配。 addProseMirrorPlugins() {
priority: 1000, const { schema } = this.editor
const bulletListType = schema.nodes.bulletList
const listItemType = schema.nodes.listItem
const taskListType = schema.nodes.taskList
const taskItemType = schema.nodes.taskItem
// 缺任一相关节点类型(如未启用 TaskList),则插件空转。
if (!bulletListType || !listItemType || !taskListType || !taskItemType) {
return []
}
addInputRules() {
return [ return [
wrappingInputRule({ new Plugin({
find: taskInputRegex, key: pluginKey,
type: this.editor.schema.nodes.taskItem, appendTransaction: (transactions, _oldState, newState) => {
getAttributes: (match) => ({ // 仅在文档实际变化时检查;非文档变化(选区移动)直接跳过。
checked: match[2].toLowerCase() === 'x', const docChanged = transactions.some((tr) => tr.docChanged)
}), if (!docChanged) return null
const { selection, tr } = newState
// 仅处理光标(非选区)输入场景。
if (!selection.empty) return null
const $from = selection.$from
// 向上查找最近的 listItem 祖先,以及它是否在 bulletList 内。
let listItemDepth = -1
for (let depth = $from.depth; depth > 0; depth--) {
if ($from.node(depth).type === listItemType) {
listItemDepth = depth
break
}
}
if (listItemDepth < 0) return null
const listItem = $from.node(listItemDepth)
// listItem 必须直接位于 bulletList 内(排除已是 taskList 的情况)。
const listParentDepth = listItemDepth - 1
if (listParentDepth < 1) return null
const listParent = $from.node(listParentDepth)
if (listParent.type !== bulletListType) return null
// 读 listItem 第一个子节点(应为段落)的文本。
const firstChild = listItem.firstChild
if (!firstChild || !firstChild.isTextblock) return null
const text = firstChild.textContent
const match = text.match(taskPrefixRegex)
if (!match) return null
// 命中:把整个 bulletList 替换成 taskList,逐个 listItem 转成 taskItem。
// 单个 listItem 命中即升级整个列表(用户可在每一行独立打 [ ] 控制 checked)。
const checked = match[1].toLowerCase() === 'x'
// 构造新的 taskList:把 bulletList 的每个 listItem 转成 taskItem。
// 当前命中的 listItem 删除 `[ ] ` 前缀并设 checked;其余 listItem 默认未勾选。
const listPos = $from.start(listParentDepth) - 1 // bulletList 节点的文档位置
const newItems: Node[] = []
listParent.forEach((itemNode) => {
const isMatched = itemNode === listItem
// 复用原 listItem 的子节点(段落等),命中的去掉前缀文本。
let children = itemNode.content
if (isMatched) {
children = stripPrefix(children, match[0].length)
}
newItems.push(
taskItemType.create(
{ checked: isMatched ? checked : false },
children,
),
)
})
const newTaskList = taskListType.create(null, newItems)
const replaceTr = tr.replaceWith(listPos, listPos + listParent.nodeSize, newTaskList)
// 保持光标在转换后的合理位置(命中的 taskItem 内,去掉前缀后)。
// replaceWith 会保留映射,但为确保光标落在文本内,显式重设到新 taskItem 末尾。
return replaceTr
},
}), }),
] ]
}, },
}) })
/**
*
* `[ ] ` ,
*/
function stripPrefix(content: Fragment, len: number) {
// content 是 Fragment;用 cut 切除开头 len 个字符位置。
// Fragment.cut(from) 返回从 from 开始的子片段。
return content.cut(len)
}