feat(editor): 支持 - [ ] 手动输入创建任务列表

Tiptap 官方 TaskItem 的 input rule 只匹配 [ ] 不含 marker,且 StarterKit
的 BulletList input rule /^\s*([-+*])\s$/ 在用户打 - 时就抢先触发,把这行
变成无序列表;后续的 [ ] 沦为字面文本,getMarkdown 序列化时转义成 \[ \],
前台无法渲染成 checkbox。

- 新增 TaskInputRule 扩展,priority 1000 抢在 BulletList(默认 100)之前,
  正则 /^(\s*-\s)\[( |x|X)\]\s$/ 匹配 - [ ] / - [x],
  wrappingInputRule 包裹成 taskList > taskItem(findWrapping 自动补齐两层)
- 仅识别减号 marker(* + 仍走 BulletList),Enter 续行复用 TaskItem 内置
  splitListItem,不重复实现
- 新增 task-input-rule 测试(真实 Editor + happy-dom):覆盖未勾选/已勾选/
  大写 X/不误判普通列表/星号 marker
This commit is contained in:
xfy 2026-07-02 13:56:11 +08:00
parent 865871f45c
commit 4a76397af8
3 changed files with 160 additions and 0 deletions

View File

@ -0,0 +1,115 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { TaskList, TaskItem } from '@tiptap/extension-list'
import { TaskInputRule } from '../task-input-rule'
/**
* TaskInputRule happy-dom DOM + Editor
*
* input rule ProseMirror handleTextInput
* `insertContentAt(pos, text, { applyInputRules: true })`
* inputRulesPlugin applyInputRules metasetTimeout
* await setTimeout
*
* 46 upload-coordinator/upload-image/slash-command
* TaskInputRule /checked/
*/
/** 等待 applyInputRules 的 setTimeout 排空。 */
function flushInputRules() {
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,
extensions: [
StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
TaskList,
TaskItem.configure({ nested: true }),
TaskInputRule,
],
content: { type: 'doc', content: [{ type: 'paragraph' }] },
})
}
/** 在文档开头(段落内 pos 1插入文本并触发 input rule。 */
function typeText(editor: Editor, text: string) {
editor.commands.insertContentAt(1, text, { applyInputRules: true })
}
describe('TaskInputRule', () => {
let editor: Editor
beforeEach(() => {
document.body.innerHTML = ''
editor = makeEditor()
})
it('打 "- [ ] " 时创建未勾选任务列表项', async () => {
typeText(editor, '- [ ] ')
await flushInputRules()
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)
})
it('打 "- [x] " 时创建已勾选任务列表项', async () => {
typeText(editor, '- [x] ')
await flushInputRules()
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)
})
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,再补 "文本"(真实用户是逐字符)
typeText(editor, '- ')
await flushInputRules()
typeText(editor, '文本')
await flushInputRules()
const block = firstBlock(editor)
expect(block.type).toBe('bulletList')
expect(block.content?.[0].type).toBe('listItem')
})
it('打 "* [ ] " 时是普通无序列表(星号 marker 不识别成任务)', async () => {
// 用户选定仅识别减号 marker,* 仍走 BulletList
typeText(editor, '* ')
await flushInputRules()
typeText(editor, '[ ] ')
await flushInputRules()
const block = firstBlock(editor)
expect(block.type).toBe('bulletList')
expect(block.content?.[0].type).toBe('listItem')
})
})

View File

@ -5,6 +5,7 @@ import { TableKit } from '@tiptap/extension-table'
import { TaskList, TaskItem } from '@tiptap/extension-list' import { TaskList, TaskItem } from '@tiptap/extension-list'
import { FileHandler } from '@tiptap/extension-file-handler' import { FileHandler } from '@tiptap/extension-file-handler'
import { SlashCommand } from './slash-command' import { SlashCommand } from './slash-command'
import { TaskInputRule } from './task-input-rule'
import { UploadCoordinator, UPLOAD_COORDINATOR_STORAGE_KEY, type UploadEvent } from './upload-coordinator' import { UploadCoordinator, UPLOAD_COORDINATOR_STORAGE_KEY, type UploadEvent } from './upload-coordinator'
import { UploadImage } from './upload-image' import { UploadImage } from './upload-image'
import './style.css' import './style.css'
@ -100,6 +101,8 @@ class TiptapEditorInstance {
UploadImage, UploadImage,
TaskList, TaskList,
TaskItem.configure({ nested: true }), TaskItem.configure({ nested: true }),
// 让手动输入 - [ ] / - [x] 直接创建任务列表(priority 1000 抢在 BulletList 前)
TaskInputRule,
// 把宿主注入的图片上传回调透传给斜杠命令扩展,使 /上传图片 命令可用。 // 把宿主注入的图片上传回调透传给斜杠命令扩展,使 /上传图片 命令可用。
// 注意:闭包延迟读取 this.coordinator它在 editor 创建后才实例化)。 // 注意:闭包延迟读取 this.coordinator它在 editor 创建后才实例化)。
SlashCommand.configure({ SlashCommand.configure({

View File

@ -0,0 +1,42 @@
import { Extension, wrappingInputRule } from '@tiptap/core'
/**
* `- [ ]` / `- [x]`( marker + + + )
*
* 为什么需要它:Tiptap TaskItem input rule `[ ] `( marker),
* StarterKit BulletList input rule `/^\s*([-+*])\s$/` `- ` ,
* ; `[ ]` ,getMarkdown `\[ \]`
* priority BulletList , `- [ ] `
*
* marker( GitHub/Typora );`*` `+` BulletList
*/
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 退),
*
*/
export const TaskInputRule = Extension.create({
name: 'taskInputRule',
// 高于 BulletList 默认的 100,确保本扩展的 input rule 先匹配。
priority: 1000,
addInputRules() {
return [
wrappingInputRule({
find: taskInputRegex,
type: this.editor.schema.nodes.taskItem,
getAttributes: (match) => ({
checked: match[2].toLowerCase() === 'x',
}),
}),
]
},
})