diff --git a/libs/tiptap-editor/src/__tests__/task-input-rule.test.ts b/libs/tiptap-editor/src/__tests__/task-input-rule.test.ts new file mode 100644 index 0000000..aa21b3f --- /dev/null +++ b/libs/tiptap-editor/src/__tests__/task-input-rule.test.ts @@ -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') + }) +}) diff --git a/libs/tiptap-editor/src/index.ts b/libs/tiptap-editor/src/index.ts index 21bfb57..c365c9c 100644 --- a/libs/tiptap-editor/src/index.ts +++ b/libs/tiptap-editor/src/index.ts @@ -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({ diff --git a/libs/tiptap-editor/src/task-input-rule.ts b/libs/tiptap-editor/src/task-input-rule.ts new file mode 100644 index 0000000..ad2ae2e --- /dev/null +++ b/libs/tiptap-editor/src/task-input-rule.ts @@ -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', + }), + }), + ] + }, +})