import { Command, EditorView } from '@codemirror/view'; export interface ToggleCheckboxOptions { autoSort?: boolean; } export interface ChecklistItemBlock { lines: string[]; isChecked: boolean; isTarget: boolean; } const CHECKLIST_REGEX = /^(\s*(?:[-*+]|\d+\.)\s+)\[([ xX])\]/; /** * 判断单行文本是否符合 Markdown 任务列表语法(如 `- [ ] 待办`、`1. [x] 已完成`) */ export const isChecklistLine = (lineText: string): boolean => { return CHECKLIST_REGEX.test(lineText); }; /** * 获取单行前置缩进空格数 */ export const getLineIndent = (lineText: string): number => { const match = lineText.match(/^\s*/); return match ? match[0].length : 0; }; /** * 对连续待办列表项执行重排(对标 Apple 备忘录自动沉底体验) * - 未勾选项保留在顶部 * - 新勾选的项沉底至已完成列表末尾 * - 取消勾选的项浮动至未完成列表末尾 */ export const reorderChecklistItems = ( items: ChecklistItemBlock[], targetChecked: boolean, ): ChecklistItemBlock[] => { const unchecked: ChecklistItemBlock[] = []; const checked: ChecklistItemBlock[] = []; let targetItem: ChecklistItemBlock | null = null; for (const item of items) { const isItemChecked = item.isTarget ? targetChecked : item.isChecked; const updatedItem: ChecklistItemBlock = { ...item, isChecked: isItemChecked, lines: item.lines.map((l, idx) => { if (item.isTarget && idx === 0) { return l.replace(CHECKLIST_REGEX, (_match, prefix) => `${prefix}[${targetChecked ? 'x' : ' '}]`); } return l; }), }; if (item.isTarget) { targetItem = updatedItem; } else if (isItemChecked) { checked.push(updatedItem); } else { unchecked.push(updatedItem); } } if (targetItem) { if (targetChecked) { // 勾选完成:沉底至已完成项的最末端 checked.push(targetItem); } else { // 取消勾选:浮动至未完成项的最末端 unchecked.push(targetItem); } } return [...unchecked, ...checked]; }; /** * 切换指定位置的复选框状态,并支持连续待办清单自动沉底 */ const toggleCheckbox = (linePos: number, options: ToggleCheckboxOptions = { autoSort: true }): Command => (target: EditorView) => { const state = target.state; if (linePos >= state.doc.length) { return false; } const line = state.doc.lineAt(linePos); const checklistMatch = line.text.match(CHECKLIST_REGEX); // 非行首标准列表项,回退到就地匹配替换 if (!checklistMatch) { const genericMatch = line.text.match(/\[([ xX])\]/); if (!genericMatch) { return false; } const isChecked = genericMatch[1].toLowerCase() === 'x'; const checkboxPos = genericMatch.index! + line.from; target.dispatch({ changes: [{ from: checkboxPos, to: checkboxPos + 3, insert: isChecked ? '[ ]' : '[x]' }], }); return true; } const isCurrentlyChecked = checklistMatch[2].toLowerCase() === 'x'; const newChecked = !isCurrentlyChecked; const shouldAutoSort = options.autoSort !== false; if (!shouldAutoSort) { const checkboxPos = checklistMatch[1].length + line.from; target.dispatch({ changes: [{ from: checkboxPos, to: checkboxPos + 3, insert: newChecked ? '[x]' : '[ ]' }], }); return true; } // 自动排序模式:查找同缩进层级的连续清单块 const baseIndent = getLineIndent(line.text); let startLineNo = line.number; let endLineNo = line.number; // 向上探测列表起始行 for (let i = line.number - 1; i >= 1; i--) { const prevLine = state.doc.line(i); if (!prevLine.text.trim()) break; // 空行阻断清单块 const indent = getLineIndent(prevLine.text); if (isChecklistLine(prevLine.text) && indent === baseIndent) { startLineNo = i; } else if (indent > baseIndent) { continue; } else { break; } } // 向下探测列表结束行 for (let i = line.number + 1; i <= state.doc.lines; i++) { const nextLine = state.doc.line(i); if (!nextLine.text.trim()) break; // 空行阻断清单块 const indent = getLineIndent(nextLine.text); if (isChecklistLine(nextLine.text) && indent === baseIndent) { endLineNo = i; } else if (indent > baseIndent) { endLineNo = i; } else { break; } } // 将行聚合成项(包含可能的子缩进行) const items: ChecklistItemBlock[] = []; let currentItem: ChecklistItemBlock | null = null; for (let i = startLineNo; i <= endLineNo; i++) { const currentDocLine = state.doc.line(i); const indent = getLineIndent(currentDocLine.text); const isItemHeader = isChecklistLine(currentDocLine.text) && indent === baseIndent; if (isItemHeader) { if (currentItem) { items.push(currentItem); } const match = currentDocLine.text.match(CHECKLIST_REGEX); currentItem = { lines: [currentDocLine.text], isChecked: match ? match[2].toLowerCase() === 'x' : false, isTarget: i === line.number, }; } else if (currentItem) { currentItem.lines.push(currentDocLine.text); } } if (currentItem) { items.push(currentItem); } // 单项列表直接就地切换,不触发重排 if (items.length <= 1) { const checkboxPos = checklistMatch[1].length + line.from; target.dispatch({ changes: [{ from: checkboxPos, to: checkboxPos + 3, insert: newChecked ? '[x]' : '[ ]' }], }); return true; } const reordered = reorderChecklistItems(items, newChecked); const newContentLines: string[] = []; for (const item of reordered) { newContentLines.push(...item.lines); } const replaceFrom = state.doc.line(startLineNo).from; const replaceTo = state.doc.line(endLineNo).to; const newText = newContentLines.join('\n'); target.dispatch({ changes: [{ from: replaceFrom, to: replaceTo, insert: newText }], }); return true; }; export default toggleCheckbox;