This commit is contained in:
2026-09-12 13:55:57 +08:00
commit 30f1cedd39
95 changed files with 42057 additions and 0 deletions

136
src/ui/task-context-menu.js Normal file
View File

@@ -0,0 +1,136 @@
// 右键菜单 items 构造 —— 与 task-list.js 共用的纯函数层。
//
// 动机test 脚本check-completed-context-menu.mjs / check-trash-context-menu.mjs
// 需要验证右键菜单的判定逻辑(已完成任务视图隐藏「移到分类…」/ 回收站里已完成
// 任务的默认恢复项 label 等),但 Node ESM 锁定 module named-export无法在测试
// 代码里 monkey-patch feedback.js.showContextMenu 来拦截 _showTaskContextMenu 调用。
// 最小可测化路径:把 items 构造抽出来变成纯函数task-list 与 check 脚本共享一份
// 判定逻辑 —— 既保证测试覆盖的是真实代码,也防止 UI 与 check 脚本后续发散。
//
// 覆盖范围:两分支 —— buildStandardContextMenuItems普通分类 / 智能视图) +
// buildTrashContextMenuItems回收站。回收站分支的 action 回调
// restore-default / restore:<name> / toggle 等)依旧留在那里 —— 它们依赖
// pickRestoreTargetForCompletedTask / pickRestoreTargetForTrashTask 等私有
// picker 路径,与 items 数组构造(纯判定)解耦更直接。
import { CategoryKind } from '../markdown-parser.js';
import { COMPLETED_NAME, UNCATEGORIZED_NAME } from '../task-store.js';
/**
* 构造「普通分类」右键菜单的 items 数组。
*
* @param {object} cat - 当前选中的 category 或 smart-list 视图
* @param {object} task - 右键选中的任务cat.tasks 里的某条;智能视图下带 _fromCategory
* @param {object} store - TaskStore 实例(用于枚举 otherCategories
* @returns {Array<{label?:string, action?:string, separator?:boolean, indent?:boolean, disabled?:boolean}>}
*/
export function buildStandardContextMenuItems(cat, task, store) {
// 智能列表视图操作真实分类_fromCategory否则 cat.name 是聚合名
const realCatName = task._fromCategory || cat.name;
// 「已完成任务」智能视图下不渲染「移到分类…」分组 —— v3.4 数据归属不变量
// task-store.js:950规定 completed=true 任务只能活在 kind=COMPLETED 里,
// 点了之后 store 会硬路由回「已完成任务」分类,等于死按钮。统一在这里短路掉,
// 让菜单里看不到这个无效入口,也避免用户点了再收到「已移到 X」但实际没动。
const isCompletedView = cat.isSmartList && cat.smartKey === 'completed';
const otherCategories = store.categories
.filter(c => c.name !== realCatName
&& c.kind !== CategoryKind.OTHER_TASKS
&& c.kind !== CategoryKind.TRASH
// 防御性过滤:理论上同名「已完成任务」分类只有一个,但保留名分类重名会让
// _consolidateCompleted 兜底删除副本parser 层保留名去重),极端情况列表
// 里能短暂存在多个 kind=COMPLETED —— 不能让它们出现在 move 目标里。
&& c.kind !== CategoryKind.COMPLETED)
.map(c => ({
label: c.name,
action: `move:${c.name}`,
indent: true
}));
// 多个分类时一次性全展开 —— 用滚动而不是 slice。
// slice(0, 6) 是历史欠账:分类多时(用户可能建十几个)后半段被静默截掉,
// 用户在右键菜单里根本看不到,找不到 = 「这个功能不存在」。
// 菜单顶部有 max-height 滚动 + 视口边距防溢出,落点太多时滚动到目标即可。
const items = [
{ label: task.completed ? '标记为未完成' : '标记为已完成', action: 'toggle' },
{ label: '编辑', action: 'edit' },
{ separator: true },
{ label: task.current ? '取消当前' : '标记为当前', action: 'current' },
{ label: task.important ? '取消星标' : '标记为重要', action: 'important' },
];
// 「移到分类…」分组仅在确实存在其它分类时才显示 —— 没有候选目标时,
// 整个「移到分类…」section 都不该出现。否则菜单里只剩一个禁用的占位 header
// 加上一个孤零零的 separator比「啥也没有」更让人困惑。
//
// 「已完成任务」视图(见上)也不显示:此时所有候选都是死按钮,整组直接消失。
if (!isCompletedView && otherCategories.length > 0) {
items.push(
{ separator: true },
{ label: '移到分类…', action: 'move-header', disabled: true },
...otherCategories.map(c => ({ label: c.label, action: c.action, indent: true }))
);
}
items.push(
{ separator: true },
// 不再标 danger删除是可逆的任务移到回收站Markdown 里仍在)
{ label: '移到回收站', action: 'delete' }
);
return items;
}
/**
* 构造「回收站」右键菜单的 items 数组。
*
* 与 buildStandardContextMenuItems 对称:把 task-list.js 里 TRASH 分支的
* items 构造抽出来,方便 check 脚本check-trash-context-menu.mjs在 Node
* 里直接验证判定逻辑,不必起 DOM/feedback.js。
*
* 唯一被 filter 过的 targets 是其他 NORMAL 子分类OTHER_TASKS / TRASH /
* COMPLETED 都不在菜单里OTHER_TASKS / TRASH 是容器自身COMPLETED 已完成
* 任务的归宿 —— 见下面 isCompletedTask 分支)。
*
* @param {object} task - 当前右键选中的回收站任务(含 completed/originalCategory/current/important
* @param {Array} targets - 候选目标分类(已过滤 OTHER_TASKS / TRASH / COMPLETED
* @returns {Array<{label?:string, action?:string, separator?:boolean, indent?:boolean, disabled?:boolean}>}
*/
export function buildTrashContextMenuItems(task, targets) {
// v3.6.4+已完成任务completed=true的默认恢复项 label 改为「已完成任务」。
// store.restoreTask 在 completed 时强制路由到「已完成任务」分类
// task-store.js:1481无视 toCategoryName用 originalCategory 提示会误导用户。
// task.originalCategory 在这种情况下仍保留 —— 留给将来"取消勾选"按它归位。
const isCompletedTask = task.completed;
const defaultTargetName = isCompletedTask
? COMPLETED_NAME
: (task.originalCategory || UNCATEGORIZED_NAME);
const defaultLabel = `恢复到「${defaultTargetName}`;
// 已完成任务的「恢复到分类…」子菜单同样隐藏 —— store.restoreTask 对
// completed 任务的强制路由task-store.js:1481会让所有"restore:*"项
// 也被短路到「已完成任务」,菜单里看起来能选具体分类,点完还是落到
// 已完成任务,与 buildStandardContextMenuItems 对「已完成任务」智能视图
// 隐藏「移到分类…」的处理对称。
const showRestorePicker = !isCompletedTask && targets.length > 1;
return [
{ label: defaultLabel, action: 'restore-default' },
{ separator: true },
{ label: task.completed ? '标记为未完成' : '标记为已完成', action: 'toggle' },
{ label: '编辑', action: 'edit' },
{ separator: true },
{ label: task.current ? '取消当前' : '标记为当前', action: 'current' },
{ label: task.important ? '取消星标' : '标记为重要', action: 'important' },
...(showRestorePicker ? [
{ separator: true },
{ label: '恢复到分类…', action: 'restore-header', disabled: true },
...targets.map(c => ({
label: c.name,
action: `restore:${c.name}`,
indent: true
}))
] : [])
];
}