一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
203 行
5.6 KiB
TypeScript
203 行
5.6 KiB
TypeScript
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;
|