diff --git a/libs/tiptap-editor/src/__tests__/task-input-rule.test.ts b/libs/tiptap-editor/src/__tests__/task-input-rule.test.ts index aa21b3f..1cb4ba7 100644 --- a/libs/tiptap-editor/src/__tests__/task-input-rule.test.ts +++ b/libs/tiptap-editor/src/__tests__/task-input-rule.test.ts @@ -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('bulletList') - expect(block.content?.[0].type).toBe('listItem') + expect(block.type).toBe('taskList') + expect(block.content?.[0]?.attrs?.checked).toBe(true) }) - it('打 "* [ ] " 时是普通无序列表(星号 marker 不识别成任务)', async () => { - // 用户选定仅识别减号 marker,* 仍走 BulletList - typeText(editor, '* ') - await flushInputRules() - typeText(editor, '[ ] ') - await flushInputRules() + 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 列表打 "[ ] " 也升级(* 触发的是 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') }) }) diff --git a/libs/tiptap-editor/src/task-input-rule.ts b/libs/tiptap-editor/src/task-input-rule.ts index ad2ae2e..0c49964 100644 --- a/libs/tiptap-editor/src/task-input-rule.ts +++ b/libs/tiptap-editor/src/task-input-rule.ts @@ -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) +}