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:
parent
865871f45c
commit
4a76397af8
115
libs/tiptap-editor/src/__tests__/task-input-rule.test.ts
Normal file
115
libs/tiptap-editor/src/__tests__/task-input-rule.test.ts
Normal 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 meta,setTimeout 异步触发),
|
||||
* 因此断言前需 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')
|
||||
})
|
||||
})
|
||||
@ -5,6 +5,7 @@ import { TableKit } from '@tiptap/extension-table'
|
||||
import { TaskList, TaskItem } from '@tiptap/extension-list'
|
||||
import { FileHandler } from '@tiptap/extension-file-handler'
|
||||
import { SlashCommand } from './slash-command'
|
||||
import { TaskInputRule } from './task-input-rule'
|
||||
import { UploadCoordinator, UPLOAD_COORDINATOR_STORAGE_KEY, type UploadEvent } from './upload-coordinator'
|
||||
import { UploadImage } from './upload-image'
|
||||
import './style.css'
|
||||
@ -100,6 +101,8 @@ class TiptapEditorInstance {
|
||||
UploadImage,
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
// 让手动输入 - [ ] / - [x] 直接创建任务列表(priority 1000 抢在 BulletList 前)
|
||||
TaskInputRule,
|
||||
// 把宿主注入的图片上传回调透传给斜杠命令扩展,使 /上传图片 命令可用。
|
||||
// 注意:闭包延迟读取 this.coordinator(它在 editor 创建后才实例化)。
|
||||
SlashCommand.configure({
|
||||
|
||||
42
libs/tiptap-editor/src/task-input-rule.ts
Normal file
42
libs/tiptap-editor/src/task-input-rule.ts
Normal 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',
|
||||
}),
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user