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:
parent
1f8147f323
commit
4a27236859
@ -6,31 +6,24 @@ import { TaskList, TaskItem } from '@tiptap/extension-list'
|
||||
import { TaskInputRule } from '../task-input-rule'
|
||||
|
||||
/**
|
||||
* TaskInputRule 单元测试(happy-dom 真实 DOM + 真实 Editor)。
|
||||
* TaskInputRule 单元测试(happy-dom 真实 DOM + 真实 Editor)。
|
||||
*
|
||||
* input rule 在真实输入时由 ProseMirror 的 handleTextInput 触发。测试里用
|
||||
* `insertContentAt(pos, text, { applyInputRules: true })` 模拟——它走与真实输入
|
||||
* 相同的 inputRulesPlugin(经 applyInputRules meta,setTimeout 异步触发),
|
||||
* 因此断言前需 await 一个宏任务让 setTimeout 排空。
|
||||
* 真实输入是逐字符的:打 `- ` 时 BulletList input rule 先触发,把这行变成
|
||||
* bulletList > listItem;再打 `[ ] ` 时 appendTransaction 监听到 listItem
|
||||
* 文本变化,把 bulletList 升级成 taskList。
|
||||
*
|
||||
* 46 个回归测试见 upload-coordinator/upload-image/slash-command,这里只覆盖
|
||||
* TaskInputRule 自身的触发/checked/不误判。
|
||||
* 测试用 insertContentAt(无 applyInputRules)模拟逐段输入:它走正常 dispatch,
|
||||
* 既触发 input rule(经 handleTextInput 路径外的 plugin)也触发 appendTransaction。
|
||||
* 为贴近逐字符时序,分两步:先 `- `(触发 BulletList),再 `[ ] `(触发升级)。
|
||||
*
|
||||
* 46 个回归测试见 upload-coordinator/upload-image/slash-command。
|
||||
*/
|
||||
|
||||
/** 等待 applyInputRules 的 setTimeout 排空。 */
|
||||
function flushInputRules() {
|
||||
/** 等待异步(input rule 的 setTimeout + appendTransaction)。 */
|
||||
function flush() {
|
||||
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() {
|
||||
return new Editor({
|
||||
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) {
|
||||
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
|
||||
|
||||
beforeEach(() => {
|
||||
@ -57,59 +62,67 @@ describe('TaskInputRule', () => {
|
||||
editor = makeEditor()
|
||||
})
|
||||
|
||||
it('打 "- [ ] " 时创建未勾选任务列表项', async () => {
|
||||
typeText(editor, '- [ ] ')
|
||||
await flushInputRules()
|
||||
it('打 "- " 再打 "[ ] " 后升级成未勾选任务列表', async () => {
|
||||
// 步骤 1:打 "- " → BulletList input rule 触发
|
||||
typeText(editor, '- ')
|
||||
await flush()
|
||||
expect(firstBlock(editor).type).toBe('bulletList')
|
||||
|
||||
// 步骤 2:在 listItem 内打 "[ ] " → appendTransaction 升级成 taskList
|
||||
typeText(editor, '[ ] ')
|
||||
await flush()
|
||||
|
||||
const block = firstBlock(editor)
|
||||
expect(block.type).toBe('taskList')
|
||||
const taskItem = block.content?.[0]
|
||||
expect(taskItem?.type).toBe('taskItem')
|
||||
expect(taskItem?.attrs?.checked).toBe(false)
|
||||
// 前缀 "[ ] " 应被删除,不残留为文本
|
||||
expect(taskItem?.content?.[0]?.content?.[0]?.text).not.toContain('[')
|
||||
})
|
||||
|
||||
it('打 "- [x] " 时创建已勾选任务列表项', async () => {
|
||||
typeText(editor, '- [x] ')
|
||||
await flushInputRules()
|
||||
it('打 "[x] " 升级成已勾选任务列表项', async () => {
|
||||
typeText(editor, '- ')
|
||||
await flush()
|
||||
typeText(editor, '[x] ')
|
||||
await flush()
|
||||
|
||||
const block = firstBlock(editor)
|
||||
expect(block.type).toBe('taskList')
|
||||
const taskItem = block.content?.[0]
|
||||
expect(taskItem?.type).toBe('taskItem')
|
||||
expect(taskItem?.attrs?.checked).toBe(true)
|
||||
expect(block.content?.[0]?.attrs?.checked).toBe(true)
|
||||
})
|
||||
|
||||
it('打 "- [X] "(大写 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,再补 "文本"(真实用户是逐字符)
|
||||
it('打 "[X] "(大写)也算已勾选', async () => {
|
||||
typeText(editor, '- ')
|
||||
await flushInputRules()
|
||||
typeText(editor, '文本')
|
||||
await flushInputRules()
|
||||
await flush()
|
||||
typeText(editor, '[X] ')
|
||||
await flush()
|
||||
|
||||
const block = firstBlock(editor)
|
||||
expect(block.type).toBe('taskList')
|
||||
expect(block.content?.[0]?.attrs?.checked).toBe(true)
|
||||
})
|
||||
|
||||
it('打普通文本不升级(保持 bulletList)', async () => {
|
||||
typeText(editor, '- ')
|
||||
await flush()
|
||||
typeText(editor, '普通项')
|
||||
await flush()
|
||||
|
||||
const block = firstBlock(editor)
|
||||
expect(block.type).toBe('bulletList')
|
||||
expect(block.content?.[0].type).toBe('listItem')
|
||||
expect(block.content?.[0]?.type).toBe('listItem')
|
||||
})
|
||||
|
||||
it('打 "* [ ] " 时是普通无序列表(星号 marker 不识别成任务)', async () => {
|
||||
// 用户选定仅识别减号 marker,* 仍走 BulletList
|
||||
it('星号 marker 列表打 "[ ] " 也升级(* 触发的是 bulletList)', async () => {
|
||||
// BulletList 对 * + - 三种 marker 都创建 bulletList 节点,
|
||||
// appendTransaction 识别到 listItem 内的 [ ] 前缀即升级,与 marker 无关。
|
||||
typeText(editor, '* ')
|
||||
await flushInputRules()
|
||||
await flush()
|
||||
typeText(editor, '[ ] ')
|
||||
await flushInputRules()
|
||||
await flush()
|
||||
|
||||
const block = firstBlock(editor)
|
||||
expect(block.type).toBe('bulletList')
|
||||
expect(block.content?.[0].type).toBe('listItem')
|
||||
expect(block.type).toBe('taskList')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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),
|
||||
* 而 StarterKit 的 BulletList input rule `/^\s*([-+*])\s$/` 在用户打 `- ` 时就抢先触发,
|
||||
* 把这行变成无序列表;后续的 `[ ]` 沦为字面文本,getMarkdown 序列化时还会转义成 `\[ \]`。
|
||||
* 本扩展用更高 priority 抢在 BulletList 之前,完整匹配 `- [ ] ` 直接创建任务列表。
|
||||
* 为什么不能用 input rule:StarterKit 的 BulletList input rule `/^\s*([-+*])\s$/`
|
||||
* 在用户打 `- `(第 2 个字符)时就触发,把这行变成 bulletList > listItem;
|
||||
* 之后用户继续打 `[ ]`,光标已在 listItem 内,任何 input rule 都无法把 listItem
|
||||
* 升级成 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$/
|
||||
|
||||
/**
|
||||
* 让手动输入 `- [ ]` / `- [x]` 直接创建任务列表项的扩展。
|
||||
*
|
||||
* 通过 `priority: 1000` 抢在 BulletList(默认 priority 100)之前执行 input rule。
|
||||
* `wrappingInputRule` 指向 taskItem 类型,ProseMirror 的 findWrapping 会自动补齐
|
||||
* `paragraph → taskList > taskItem` 的两层包裹路径(taskItem 的 schema 要求它在 taskList 内)。
|
||||
*
|
||||
* Enter 续行复用 TaskItem 内置的 splitListItem(新建项 checked=false,空项再 Enter 退出),
|
||||
* 这里不重复实现。
|
||||
*/
|
||||
const pluginKey = new PluginKey('taskListAutoConvert')
|
||||
|
||||
/** 匹配 listItem 内刚打出的 `[ ] ` / `[x] ` / `[X] ` 前缀。 */
|
||||
const taskPrefixRegex = /^\[([ xX])\]\s/
|
||||
|
||||
export const TaskInputRule = Extension.create({
|
||||
name: 'taskInputRule',
|
||||
|
||||
// 高于 BulletList 默认的 100,确保本扩展的 input rule 先匹配。
|
||||
priority: 1000,
|
||||
addProseMirrorPlugins() {
|
||||
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 [
|
||||
wrappingInputRule({
|
||||
find: taskInputRegex,
|
||||
type: this.editor.schema.nodes.taskItem,
|
||||
getAttributes: (match) => ({
|
||||
checked: match[2].toLowerCase() === 'x',
|
||||
}),
|
||||
new Plugin({
|
||||
key: pluginKey,
|
||||
appendTransaction: (transactions, _oldState, newState) => {
|
||||
// 仅在文档实际变化时检查;非文档变化(选区移动)直接跳过。
|
||||
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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user