feat(task): add multi-task selection and bulk operations

Add comprehensive task selection and bulk operation capabilities:

- Add TaskSelectionManager for managing task selection state
- Add LongPressDetector for mobile gesture support
- Add BulkOperationsMenu with operations for selected tasks
- Support shift-click and ctrl-click for multi-selection
- Support long-press on mobile to enter selection mode
- Add visual feedback for selected tasks with highlighting
- Add floating badge showing selected task count
- Add write queue in WriteAPI to prevent concurrent writes
- Add updateTasksSequentially API for batch task updates
- Integrate selection manager into task list and tree views

Bulk operations supported:
- Set due date (absolute or relative offset)
- Set start date
- Set priority
- Add/remove tags
- Set status
- Archive tasks
- Delete tasks

New files:
- src/components/features/task/selection/TaskSelectionManager.ts
- src/components/features/task/selection/LongPressDetector.ts
- src/components/features/task/view/BulkOperationsMenu.ts
- src/styles/task-selection.css
- src/types/selection.d.ts
This commit is contained in:
Quorafind 2025-10-19 13:44:12 +08:00
parent dee954193f
commit 852f527fd0
11 changed files with 2307 additions and 114 deletions

View file

@ -461,6 +461,12 @@ const createMockPlugin = (
const mockWriteAPI = {
updateTask: jest.fn(async () => ({ success: true })),
updateTasksSequentially: jest.fn(async (args: any[]) => ({
successCount: args?.length ?? 0,
failCount: 0,
errors: [],
totalCount: args?.length ?? 0,
})),
createTask: jest.fn(async () => ({ success: true })),
deleteTask: jest.fn(async () => ({ success: true })),
};

View file

@ -0,0 +1,161 @@
/**
* Long press detector for mobile devices
* Detects when user long-presses an element to enter selection mode
*/
import { Component } from "obsidian";
import { LongPressOptions } from "@/types/selection";
export class LongPressDetector extends Component {
private timers: Map<HTMLElement, number> = new Map();
private startPositions: Map<HTMLElement, { x: number; y: number }> =
new Map();
private activeElements: Set<HTMLElement> = new Set();
/**
* Start detecting long press on an element
*/
public startDetection(
element: HTMLElement,
options: LongPressOptions
): void {
const threshold = options.threshold || 500;
const moveTolerance = options.moveTolerance || 10;
// Cleanup previous detection on this element
this.stopDetection(element);
// Touch start handler
const handleTouchStart = (e: TouchEvent) => {
// Ignore if already in a long press
if (this.timers.has(element)) {
return;
}
const touch = e.touches[0];
if (!touch) return;
// Store start position
this.startPositions.set(element, {
x: touch.clientX,
y: touch.clientY,
});
// Trigger start callback
if (options.onLongPressStart) {
options.onLongPressStart();
}
// Set timer for long press detection
const timer = window.setTimeout(() => {
// Long press detected
this.timers.delete(element);
this.startPositions.delete(element);
options.onLongPress();
}, threshold);
this.timers.set(element, timer);
};
// Touch move handler - cancel if moved too far
const handleTouchMove = (e: TouchEvent) => {
const timer = this.timers.get(element);
if (!timer) return;
const touch = e.touches[0];
if (!touch) return;
const startPos = this.startPositions.get(element);
if (!startPos) return;
// Calculate movement
const deltaX = Math.abs(touch.clientX - startPos.x);
const deltaY = Math.abs(touch.clientY - startPos.y);
// Cancel if moved too far
if (deltaX > moveTolerance || deltaY > moveTolerance) {
this.cancelDetection(element, options);
}
};
// Touch end/cancel handlers
const handleTouchEnd = () => {
this.cancelDetection(element, options);
};
const handleTouchCancel = () => {
this.cancelDetection(element, options);
};
// Register event listeners
this.registerDomEvent(element, "touchstart", handleTouchStart, {
passive: true,
});
this.registerDomEvent(element, "touchmove", handleTouchMove, {
passive: true,
});
this.registerDomEvent(element, "touchend", handleTouchEnd);
this.registerDomEvent(element, "touchcancel", handleTouchCancel);
// Mark element as active
this.activeElements.add(element);
}
/**
* Stop detecting long press on an element
*/
public stopDetection(element: HTMLElement): void {
// Clear timer
const timer = this.timers.get(element);
if (timer) {
clearTimeout(timer);
this.timers.delete(element);
}
// Clear position
this.startPositions.delete(element);
// Remove from active elements
this.activeElements.delete(element);
}
/**
* Cancel long press detection
*/
private cancelDetection(
element: HTMLElement,
options: LongPressOptions
): void {
const timer = this.timers.get(element);
if (timer) {
clearTimeout(timer);
this.timers.delete(element);
this.startPositions.delete(element);
// Trigger cancel callback
if (options.onLongPressCancel) {
options.onLongPressCancel();
}
}
}
/**
* Cleanup all detections
*/
public cleanup(): void {
// Clear all timers
for (const timer of this.timers.values()) {
clearTimeout(timer);
}
this.timers.clear();
this.startPositions.clear();
this.activeElements.clear();
}
/**
* Component unload
*/
onunload(): void {
this.cleanup();
}
}

View file

@ -0,0 +1,228 @@
/**
* Task Selection Manager
* Manages multi-selection state for tasks and coordinates bulk operations
*/
import { App, Component } from "obsidian";
import { Task } from "@/types/task";
import {
SelectionState,
SelectionEventData,
SelectionModeChangeEventData,
} from "@/types/selection";
import { LongPressDetector } from "./LongPressDetector";
import TaskProgressBarPlugin from "@/index";
export class TaskSelectionManager extends Component {
private selectionState: SelectionState;
public longPressDetector: LongPressDetector;
// Cache for quick lookup
private taskCache: Map<string, Task> = new Map();
constructor(
private app: App,
private plugin: TaskProgressBarPlugin
) {
super();
// Initialize selection state
this.selectionState = {
selectedTaskIds: new Set<string>(),
isSelectionMode: false,
};
// Initialize long press detector
this.longPressDetector = new LongPressDetector();
this.addChild(this.longPressDetector);
}
/**
* Get current selection state (readonly)
*/
public getSelectionState(): Readonly<SelectionState> {
return {
selectedTaskIds: new Set(this.selectionState.selectedTaskIds),
isSelectionMode: this.selectionState.isSelectionMode,
selectionModeStartTime:
this.selectionState.selectionModeStartTime,
};
}
/**
* Check if selection mode is active
*/
public get isSelectionMode(): boolean {
return this.selectionState.isSelectionMode;
}
/**
* Toggle task selection
*/
public toggleSelection(taskId: string): void {
if (this.selectionState.selectedTaskIds.has(taskId)) {
this.deselectTask(taskId);
} else {
this.selectTask(taskId);
}
}
/**
* Select a task
*/
public selectTask(taskId: string): void {
this.selectionState.selectedTaskIds.add(taskId);
this.notifySelectionChanged();
}
/**
* Deselect a task
*/
public deselectTask(taskId: string): void {
this.selectionState.selectedTaskIds.delete(taskId);
this.notifySelectionChanged();
// Exit selection mode if no tasks selected
if (
this.selectionState.selectedTaskIds.size === 0 &&
this.selectionState.isSelectionMode
) {
this.exitSelectionMode("operation_complete");
}
}
/**
* Select all tasks from provided list
*/
public selectAll(taskIds: string[]): void {
for (const taskId of taskIds) {
this.selectionState.selectedTaskIds.add(taskId);
}
this.notifySelectionChanged();
}
/**
* Clear all selections
*/
public clearSelection(): void {
this.selectionState.selectedTaskIds.clear();
this.notifySelectionChanged();
}
/**
* Check if a task is selected
*/
public isTaskSelected(taskId: string): boolean {
return this.selectionState.selectedTaskIds.has(taskId);
}
/**
* Get count of selected tasks
*/
public getSelectedCount(): number {
return this.selectionState.selectedTaskIds.size;
}
/**
* Get selected task IDs as array
*/
public getSelectedTaskIds(): string[] {
return Array.from(this.selectionState.selectedTaskIds);
}
/**
* Get selected task objects
*/
public getSelectedTasks(): Task[] {
const tasks: Task[] = [];
for (const taskId of this.selectionState.selectedTaskIds) {
const task = this.taskCache.get(taskId);
if (task) {
tasks.push(task);
}
}
return tasks;
}
/**
* Update task cache for quick lookup
*/
public updateTaskCache(tasks: Task[]): void {
this.taskCache.clear();
for (const task of tasks) {
this.taskCache.set(task.id, task);
}
}
/**
* Enter selection mode
*/
public enterSelectionMode(): void {
if (this.selectionState.isSelectionMode) {
return; // Already in selection mode
}
this.selectionState.isSelectionMode = true;
this.selectionState.selectionModeStartTime = Date.now();
this.notifySelectionModeChanged("user_action");
}
/**
* Exit selection mode
*/
public exitSelectionMode(
reason: SelectionModeChangeEventData["reason"] = "user_action"
): void {
if (!this.selectionState.isSelectionMode) {
return; // Already exited
}
this.selectionState.isSelectionMode = false;
this.selectionState.selectionModeStartTime = undefined;
this.clearSelection();
this.notifySelectionModeChanged(reason);
}
/**
* Notify selection changed
*/
private notifySelectionChanged(): void {
const eventData: SelectionEventData = {
selectedTaskIds: this.getSelectedTaskIds(),
isSelectionMode: this.selectionState.isSelectionMode,
count: this.getSelectedCount(),
};
(this.app.workspace as any).trigger(
"task-genius:selection-changed",
eventData
);
}
/**
* Notify selection mode changed
*/
private notifySelectionModeChanged(
reason: SelectionModeChangeEventData["reason"]
): void {
const eventData: SelectionModeChangeEventData = {
isSelectionMode: this.selectionState.isSelectionMode,
reason,
};
(this.app.workspace as any).trigger(
"task-genius:selection-mode-changed",
eventData
);
}
/**
* Component unload
*/
onunload(): void {
this.clearSelection();
this.taskCache.clear();
}
}

View file

@ -0,0 +1,905 @@
/**
* Bulk Operations Menu
* Provides right-click context menu for bulk task operations
*/
import { App, Menu, Notice } from "obsidian";
import { Task } from "@/types/task";
import { TaskSelectionManager } from "@/components/features/task/selection/TaskSelectionManager";
import TaskProgressBarPlugin from "@/index";
import { t } from "@/translations/helper";
import { BulkOperationResult } from "@/types/selection";
import { createTaskCheckbox } from "./details";
import { ConfirmModal } from "@/components/ui";
import type { UpdateTaskArgs } from "@/dataflow/api/WriteAPI";
import { BulkDatePickerModal } from "@/components/ui/date-picker/BulkDatePickerModal";
import { BulkDateOffsetModal } from "@/components/ui/date-picker/BulkDateOffsetModal";
import { addDays } from "date-fns";
/**
* Create and show bulk operations menu
*/
export function showBulkOperationsMenu(
event: MouseEvent,
app: App,
plugin: TaskProgressBarPlugin,
selectionManager: TaskSelectionManager,
onOperationComplete: () => void
): void {
const menu = new Menu();
const selectedCount = selectionManager.getSelectedCount();
// Menu title
menu.addItem((item) => {
item.setTitle(`${selectedCount} tasks selected`);
item.setDisabled(true);
});
menu.addSeparator();
// Bulk change status
addBulkStatusChangeMenu(menu, plugin, selectionManager, onOperationComplete);
// Bulk set dates
addBulkSetDateMenu(menu, app, plugin, selectionManager, onOperationComplete);
// Bulk set priority
addBulkSetPriorityMenu(menu, plugin, selectionManager, onOperationComplete);
// Bulk move to project
addBulkMoveToProjectMenu(menu, plugin, selectionManager, onOperationComplete);
menu.addSeparator();
// Bulk delete
addBulkDeleteMenu(menu, plugin, selectionManager, onOperationComplete);
menu.addSeparator();
// Selection management
addSelectionManagementMenu(menu, selectionManager);
// Show menu at mouse position
menu.showAtMouseEvent(event);
}
/**
* Add bulk status change submenu
*/
function addBulkStatusChangeMenu(
menu: Menu,
plugin: TaskProgressBarPlugin,
selectionManager: TaskSelectionManager,
onOperationComplete: () => void
): void {
menu.addItem((item) => {
item.setIcon("square-pen");
item.setTitle(t("Bulk change status"));
const submenu = item.setSubmenu();
// Get unique statuses from taskStatusMarks
const statusMarks = plugin.settings.taskStatusMarks;
const uniqueStatuses = new Map<string, string>();
// Build a map of unique mark -> status name to avoid duplicates
for (const status of Object.keys(statusMarks)) {
const mark = statusMarks[status as keyof typeof statusMarks];
if (!Array.from(uniqueStatuses.values()).includes(mark)) {
uniqueStatuses.set(status, mark);
}
}
// Create menu items from unique statuses
for (const [status, mark] of uniqueStatuses) {
submenu.addItem((subItem) => {
// Create checkbox indicator
subItem.titleEl.createEl(
"span",
{
cls: "status-option-checkbox",
},
(el) => {
createTaskCheckbox(mark, {} as Task, el);
}
);
subItem.titleEl.createEl("span", {
cls: "status-option",
text: status,
});
subItem.onClick(async () => {
await executeBulkStatusChange(
mark,
status,
plugin,
selectionManager,
onOperationComplete
);
});
});
}
});
}
/**
* Add bulk set date submenu
*/
function addBulkSetDateMenu(
menu: Menu,
app: App,
plugin: TaskProgressBarPlugin,
selectionManager: TaskSelectionManager,
onOperationComplete: () => void
): void {
menu.addItem((item) => {
item.setIcon("calendar");
item.setTitle(t("Bulk set date"));
const dateSubmenu = item.setSubmenu();
// Set specific date submenu
const setDateSubmenu = dateSubmenu.addItem((subItem) => {
subItem.setTitle(t("Set specific date"));
return subItem.setSubmenu();
});
setDateSubmenu.addItem((subItem) => {
subItem.setTitle(t("Due date"));
subItem.onClick(async () => {
await showDatePickerForBulk(
"dueDate",
app,
plugin,
selectionManager,
onOperationComplete
);
});
});
setDateSubmenu.addItem((subItem) => {
subItem.setTitle(t("Start date"));
subItem.onClick(async () => {
await showDatePickerForBulk(
"startDate",
app,
plugin,
selectionManager,
onOperationComplete
);
});
});
setDateSubmenu.addItem((subItem) => {
subItem.setTitle(t("Scheduled date"));
subItem.onClick(async () => {
await showDatePickerForBulk(
"scheduledDate",
app,
plugin,
selectionManager,
onOperationComplete
);
});
});
// Postpone by submenu
const postponeSubmenu = dateSubmenu.addItem((subItem) => {
subItem.setTitle(t("Postpone by..."));
return subItem.setSubmenu();
});
postponeSubmenu.addItem((subItem) => {
subItem.setTitle(t("Due date"));
subItem.onClick(async () => {
await showDateOffsetForBulk(
"dueDate",
app,
plugin,
selectionManager,
onOperationComplete
);
});
});
postponeSubmenu.addItem((subItem) => {
subItem.setTitle(t("Start date"));
subItem.onClick(async () => {
await showDateOffsetForBulk(
"startDate",
app,
plugin,
selectionManager,
onOperationComplete
);
});
});
postponeSubmenu.addItem((subItem) => {
subItem.setTitle(t("Scheduled date"));
subItem.onClick(async () => {
await showDateOffsetForBulk(
"scheduledDate",
app,
plugin,
selectionManager,
onOperationComplete
);
});
});
dateSubmenu.addSeparator();
// Clear dates submenu
const clearSubmenu = dateSubmenu.addItem((subItem) => {
subItem.setTitle(t("Clear dates"));
return subItem.setSubmenu();
});
clearSubmenu.addItem((subItem) => {
subItem.setTitle(t("Clear due date"));
subItem.onClick(async () => {
await executeBulkClearDate(
"dueDate",
plugin,
selectionManager,
onOperationComplete
);
});
});
clearSubmenu.addItem((subItem) => {
subItem.setTitle(t("Clear start date"));
subItem.onClick(async () => {
await executeBulkClearDate(
"startDate",
plugin,
selectionManager,
onOperationComplete
);
});
});
clearSubmenu.addItem((subItem) => {
subItem.setTitle(t("Clear scheduled date"));
subItem.onClick(async () => {
await executeBulkClearDate(
"scheduledDate",
plugin,
selectionManager,
onOperationComplete
);
});
});
});
}
/**
* Add bulk set priority submenu
*/
function addBulkSetPriorityMenu(
menu: Menu,
plugin: TaskProgressBarPlugin,
selectionManager: TaskSelectionManager,
onOperationComplete: () => void
): void {
menu.addItem((item) => {
item.setIcon("alert-triangle");
item.setTitle(t("Bulk set priority"));
const submenu = item.setSubmenu();
// Priority levels 1-5
for (let priority = 1; priority <= 5; priority++) {
submenu.addItem((subItem) => {
subItem.setTitle(`${"!".repeat(priority)} Priority ${priority}`);
subItem.onClick(async () => {
await executeBulkSetPriority(
priority,
plugin,
selectionManager,
onOperationComplete
);
});
});
}
submenu.addSeparator();
// Clear priority
submenu.addItem((subItem) => {
subItem.setTitle(t("Clear priority"));
subItem.onClick(async () => {
await executeBulkClearPriority(
plugin,
selectionManager,
onOperationComplete
);
});
});
});
}
/**
* Add bulk move to project submenu
*/
function addBulkMoveToProjectMenu(
menu: Menu,
plugin: TaskProgressBarPlugin,
selectionManager: TaskSelectionManager,
onOperationComplete: () => void
): void {
menu.addItem((item) => {
item.setIcon("folder");
item.setTitle(t("Bulk move to project"));
const submenu = item.setSubmenu();
// Get all projects from settings
const projects = getProjectList(plugin);
if (projects.length === 0) {
submenu.addItem((subItem) => {
subItem.setTitle(t("No projects available"));
subItem.setDisabled(true);
});
return;
}
// Add each project
projects.forEach((project) => {
submenu.addItem((subItem) => {
subItem.setTitle(project);
subItem.onClick(async () => {
await executeBulkMoveToProject(
project,
plugin,
selectionManager,
onOperationComplete
);
});
});
});
submenu.addSeparator();
// Clear project
submenu.addItem((subItem) => {
subItem.setTitle(t("Clear project"));
subItem.onClick(async () => {
await executeBulkClearProject(
plugin,
selectionManager,
onOperationComplete
);
});
});
});
}
/**
* Add bulk delete menu item
*/
function addBulkDeleteMenu(
menu: Menu,
plugin: TaskProgressBarPlugin,
selectionManager: TaskSelectionManager,
onOperationComplete: () => void
): void {
menu.addItem((item) => {
item.setIcon("trash");
item.setTitle(t("Bulk delete"));
item.onClick(async () => {
const count = selectionManager.getSelectedCount();
// Show confirmation modal
const confirmed = await new Promise<boolean>((resolve) => {
new ConfirmModal(plugin, {
title: t("Confirm bulk delete"),
message: t("Are you sure you want to delete {count} tasks?", {
count: count.toString(),
}),
confirmText: t("Delete"),
cancelText: t("Cancel"),
onConfirm: (result: boolean) => resolve(result),
}).open();
});
if (confirmed) {
await executeBulkDelete(
plugin,
selectionManager,
onOperationComplete
);
}
});
});
}
/**
* Add selection management menu items
*/
function addSelectionManagementMenu(
menu: Menu,
selectionManager: TaskSelectionManager
): void {
menu.addItem((item) => {
item.setTitle(t("Clear selection"));
item.setIcon("x");
item.onClick(() => {
selectionManager.clearSelection();
selectionManager.exitSelectionMode("user_action");
});
});
}
// ============================================================================
// Execution Functions
// ============================================================================
/**
* Execute bulk status change
*/
async function executeBulkStatusChange(
newStatus: string,
statusName: string,
plugin: TaskProgressBarPlugin,
selectionManager: TaskSelectionManager,
onOperationComplete: () => void
): Promise<void> {
const tasks = selectionManager.getSelectedTasks();
const result = await executeBulkOperation(
tasks,
(task) => {
const isCompletedStatus = plugin.settings.taskStatuses.completed
.split("|")
.includes(newStatus.toLowerCase());
// Only return the fields that need to be updated
const updates: Partial<Task> = {
status: newStatus,
completed: isCompletedStatus,
};
// Update completed date metadata if status changed
if (isCompletedStatus && !task.completed) {
updates.metadata = {
...task.metadata,
completedDate: Date.now(),
};
} else if (!isCompletedStatus && task.completed) {
updates.metadata = {
...task.metadata,
completedDate: undefined,
};
}
return updates;
},
plugin
);
showOperationResult(result, `Changed status to "${statusName}"`);
selectionManager.exitSelectionMode("operation_complete");
onOperationComplete();
}
/**
* Show date picker modal for bulk operation (specific date)
*/
async function showDatePickerForBulk(
dateType: "dueDate" | "startDate" | "scheduledDate",
app: App,
plugin: TaskProgressBarPlugin,
selectionManager: TaskSelectionManager,
onOperationComplete: () => void
): Promise<void> {
const modal = new BulkDatePickerModal(app, plugin, dateType);
modal.onDateSelected = async (timestamp: number) => {
await executeBulkSetDate(
dateType,
timestamp,
plugin,
selectionManager,
onOperationComplete
);
};
modal.open();
}
/**
* Show date offset modal for bulk operation (postpone by X days)
*/
async function showDateOffsetForBulk(
dateType: "dueDate" | "startDate" | "scheduledDate",
app: App,
plugin: TaskProgressBarPlugin,
selectionManager: TaskSelectionManager,
onOperationComplete: () => void
): Promise<void> {
const modal = new BulkDateOffsetModal(app, plugin, dateType);
modal.onOffsetSelected = async (offsetDays: number) => {
await executeBulkOffsetDate(
dateType,
offsetDays,
plugin,
selectionManager,
onOperationComplete
);
};
modal.open();
}
/**
* Execute bulk set date (specific date)
*/
async function executeBulkSetDate(
dateType: "dueDate" | "startDate" | "scheduledDate",
timestamp: number,
plugin: TaskProgressBarPlugin,
selectionManager: TaskSelectionManager,
onOperationComplete: () => void
): Promise<void> {
const tasks = selectionManager.getSelectedTasks();
const result = await executeBulkOperation(
tasks,
(task) => {
return {
metadata: {
...task.metadata,
[dateType]: timestamp,
},
};
},
plugin
);
showOperationResult(result, `Set ${dateType}`);
selectionManager.exitSelectionMode("operation_complete");
onOperationComplete();
}
/**
* Execute bulk offset date (postpone/advance by X days)
*/
async function executeBulkOffsetDate(
dateType: "dueDate" | "startDate" | "scheduledDate",
offsetDays: number,
plugin: TaskProgressBarPlugin,
selectionManager: TaskSelectionManager,
onOperationComplete: () => void
): Promise<void> {
const tasks = selectionManager.getSelectedTasks();
const result = await executeBulkOperation(
tasks,
(task) => {
// Get current date or use today as base
const currentTimestamp = task.metadata?.[dateType];
const baseDate = currentTimestamp ? new Date(currentTimestamp) : new Date();
// Calculate new date
const newDate = addDays(baseDate, offsetDays);
const newTimestamp = newDate.getTime();
return {
metadata: {
...task.metadata,
[dateType]: newTimestamp,
},
};
},
plugin
);
const action = offsetDays > 0 ? "Postponed" : "Advanced";
const daysText = Math.abs(offsetDays) === 1 ? "day" : "days";
showOperationResult(result, `${action} ${dateType} by ${Math.abs(offsetDays)} ${daysText}`);
selectionManager.exitSelectionMode("operation_complete");
onOperationComplete();
}
/**
* Execute bulk clear date
*/
async function executeBulkClearDate(
dateType: "dueDate" | "startDate" | "scheduledDate",
plugin: TaskProgressBarPlugin,
selectionManager: TaskSelectionManager,
onOperationComplete: () => void
): Promise<void> {
const tasks = selectionManager.getSelectedTasks();
const result = await executeBulkOperation(
tasks,
(task) => {
return {
metadata: {
...task.metadata,
[dateType]: undefined,
},
};
},
plugin
);
showOperationResult(result, `Cleared ${dateType}`);
selectionManager.exitSelectionMode("operation_complete");
onOperationComplete();
}
/**
* Execute bulk set priority
*/
async function executeBulkSetPriority(
priority: number,
plugin: TaskProgressBarPlugin,
selectionManager: TaskSelectionManager,
onOperationComplete: () => void
): Promise<void> {
const tasks = selectionManager.getSelectedTasks();
const result = await executeBulkOperation(
tasks,
(task) => {
return {
metadata: {
...task.metadata,
priority,
},
};
},
plugin
);
showOperationResult(result, `Set priority to ${priority}`);
selectionManager.exitSelectionMode("operation_complete");
onOperationComplete();
}
/**
* Execute bulk clear priority
*/
async function executeBulkClearPriority(
plugin: TaskProgressBarPlugin,
selectionManager: TaskSelectionManager,
onOperationComplete: () => void
): Promise<void> {
const tasks = selectionManager.getSelectedTasks();
const result = await executeBulkOperation(
tasks,
(task) => {
return {
metadata: {
...task.metadata,
priority: undefined,
},
};
},
plugin
);
showOperationResult(result, "Cleared priority");
selectionManager.exitSelectionMode("operation_complete");
onOperationComplete();
}
/**
* Execute bulk move to project
*/
async function executeBulkMoveToProject(
project: string,
plugin: TaskProgressBarPlugin,
selectionManager: TaskSelectionManager,
onOperationComplete: () => void
): Promise<void> {
const tasks = selectionManager.getSelectedTasks();
const result = await executeBulkOperation(
tasks,
(task) => {
return {
metadata: {
...task.metadata,
project,
},
};
},
plugin
);
showOperationResult(result, `Moved to project "${project}"`);
selectionManager.exitSelectionMode("operation_complete");
onOperationComplete();
}
/**
* Execute bulk clear project
*/
async function executeBulkClearProject(
plugin: TaskProgressBarPlugin,
selectionManager: TaskSelectionManager,
onOperationComplete: () => void
): Promise<void> {
const tasks = selectionManager.getSelectedTasks();
const result = await executeBulkOperation(
tasks,
(task) => {
return {
metadata: {
...task.metadata,
project: undefined,
},
};
},
plugin
);
showOperationResult(result, "Cleared project");
selectionManager.exitSelectionMode("operation_complete");
onOperationComplete();
}
/**
* Execute bulk delete
*/
async function executeBulkDelete(
plugin: TaskProgressBarPlugin,
selectionManager: TaskSelectionManager,
onOperationComplete: () => void
): Promise<void> {
const tasks = selectionManager.getSelectedTasks();
let successCount = 0;
let failCount = 0;
const errors: BulkOperationResult["errors"] = [];
for (const task of tasks) {
try {
if (!plugin.writeAPI) {
throw new Error("WriteAPI not available");
}
const result = await plugin.writeAPI.deleteTask({
taskId: task.id,
deleteChildren: false,
});
if (result.success) {
successCount++;
} else {
failCount++;
errors.push({
taskId: task.id,
taskContent: task.content,
error: result.error || "Unknown error",
});
}
} catch (error) {
failCount++;
errors.push({
taskId: task.id,
taskContent: task.content,
error: (error as Error).message || "Unknown error",
});
}
}
const result: BulkOperationResult = {
successCount,
failCount,
errors,
totalCount: tasks.length,
};
showOperationResult(result, "Deleted tasks");
selectionManager.exitSelectionMode("operation_complete");
onOperationComplete();
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Generic bulk operation executor
* Note: The operation function should return only the fields that need to be updated
*/
async function executeBulkOperation(
tasks: Task[],
operation: (task: Task) => Partial<Task>,
plugin: TaskProgressBarPlugin
): Promise<BulkOperationResult> {
if (!plugin.writeAPI) {
return {
successCount: 0,
failCount: tasks.length,
errors: tasks.map((task) => ({
taskId: task.id,
taskContent: task.content,
error: "WriteAPI not available",
})),
totalCount: tasks.length,
};
}
const updateRequests: UpdateTaskArgs[] = [];
const originalContent = new Map<string, string>();
for (const task of tasks) {
// Operation now returns only the fields to update, not the full task
const updates = operation(task);
updateRequests.push({
taskId: task.id,
updates,
});
originalContent.set(task.id, task.content);
}
const result = await plugin.writeAPI.updateTasksSequentially(
updateRequests
);
const normalizedErrors = result.errors.map((error) => ({
...error,
taskContent:
error.taskContent ||
originalContent.get(error.taskId) ||
error.taskContent,
}));
return {
...result,
errors: normalizedErrors,
};
}
/**
* Show operation result notification
*/
function showOperationResult(result: BulkOperationResult, operation: string): void {
if (result.failCount === 0) {
new Notice(
`${operation}: ${result.successCount} tasks updated successfully`
);
} else {
new Notice(
`${operation}: ${result.successCount} succeeded, ${result.failCount} failed`
);
// Log errors to console
if (result.errors.length > 0) {
console.error("Bulk operation errors:", result.errors);
}
}
}
/**
* Get list of all projects from dataflow orchestrator
*/
function getProjectList(plugin: TaskProgressBarPlugin): string[] {
// Get projects from dataflow orchestrator
const projects = new Set<string>();
// Try to access task data through dataflow orchestrator
if (plugin.dataflowOrchestrator) {
try {
// Access the taskStore from dataflow orchestrator
const taskStore = (plugin.dataflowOrchestrator as any).taskStore;
if (taskStore && taskStore.getAllTasks) {
const allTasks = taskStore.getAllTasks();
for (const task of allTasks) {
if (task.metadata?.project) {
projects.add(task.metadata.project);
}
}
}
} catch (error) {
console.warn("Could not access tasks for project list:", error);
}
}
// Fallback: Return common project names if no tasks found
if (projects.size === 0) {
return [];
}
return Array.from(projects).sort();
}

View file

@ -11,12 +11,14 @@ import { getInitialViewMode, saveViewMode } from "@/utils/ui/view-mode-utils";
// @ts-ignore
import { filterTasks } from "@/utils/task/task-filter-utils";
import { sortTasks } from "@/commands/sortTaskCommands"; // 导入 sortTasks 函数
import { TaskSelectionManager } from "@/components/features/task/selection/TaskSelectionManager";
interface ContentComponentParams {
onTaskSelected?: (task: Task | null) => void;
onTaskCompleted?: (task: Task) => void;
onTaskUpdate?: (originalTask: Task, updatedTask: Task) => Promise<void>;
onTaskContextMenu?: (event: MouseEvent, task: Task) => void;
selectionManager?: TaskSelectionManager;
}
export class ContentComponent extends Component {
@ -583,6 +585,7 @@ export class ContentComponent extends Component {
this.currentViewId, // Pass currentViewId
this.app,
this.plugin,
this.params.selectionManager // Pass selection manager
);
// Attach event handlers
@ -643,6 +646,7 @@ export class ContentComponent extends Component {
childTasks,
taskMap,
this.plugin,
this.params.selectionManager // Pass selection manager
);
// Attach event handlers
@ -823,6 +827,7 @@ export class ContentComponent extends Component {
this.currentViewId,
this.app,
this.plugin,
this.params.selectionManager // Pass selection manager
);
// Attach events
taskComponent.onTaskSelected = this.selectTask.bind(this);
@ -936,6 +941,7 @@ export class ContentComponent extends Component {
childTasks,
taskMap,
this.plugin,
this.params.selectionManager // Pass selection manager
);
newRoot.onTaskSelected = this.selectTask.bind(this);
newRoot.onTaskCompleted = (t) => {

View file

@ -1,4 +1,12 @@
import { App, Component, Menu, setIcon, Keymap } from "obsidian";
import {
App,
Component,
Menu,
setIcon,
Keymap,
Platform,
Workspace,
} from "obsidian";
import { Task } from "@/types/task";
import { MarkdownRendererComponent } from "@/components/ui/renderers/MarkdownRenderer";
import "@/styles/task-list.css";
@ -10,6 +18,8 @@ import { TaskProgressBarSettings } from "@/common/setting-definition";
import { InlineEditor, InlineEditorOptions } from "./InlineEditor";
import { InlineEditorManager } from "./InlineEditorManager";
import { sanitizePriorityForClass } from "@/utils/task/priority-utils";
import { TaskSelectionManager } from "@/components/features/task/selection/TaskSelectionManager";
import { showBulkOperationsMenu } from "./BulkOperationsMenu";
export class TaskListItemComponent extends Component {
public element: HTMLElement;
@ -33,17 +43,25 @@ export class TaskListItemComponent extends Component {
// Use shared editor manager instead of individual editors
private static editorManager: InlineEditorManager | null = null;
// Selection management
private selectionManager: TaskSelectionManager | null = null;
private isTaskSelectedState: boolean = false;
constructor(
private task: Task,
private viewMode: string,
private app: App,
private plugin: TaskProgressBarPlugin
private plugin: TaskProgressBarPlugin,
selectionManager?: TaskSelectionManager,
) {
super();
// Store selection manager reference
this.selectionManager = selectionManager || null;
this.element = createEl("div", {
cls: "task-item",
attr: {"data-task-id": this.task.id},
attr: { "data-task-id": this.task.id },
});
this.settings = this.plugin.settings;
@ -52,7 +70,7 @@ export class TaskListItemComponent extends Component {
if (!TaskListItemComponent.editorManager) {
TaskListItemComponent.editorManager = new InlineEditorManager(
this.app,
this.plugin
this.plugin,
);
}
}
@ -68,7 +86,7 @@ export class TaskListItemComponent extends Component {
try {
await this.onTaskUpdate(originalTask, updatedTask);
console.log(
"listItem onTaskUpdate completed successfully"
"listItem onTaskUpdate completed successfully",
);
// Don't update task reference here - let onContentEditFinished handle it
} catch (error) {
@ -81,7 +99,7 @@ export class TaskListItemComponent extends Component {
},
onContentEditFinished: (
targetEl: HTMLElement,
updatedTask: Task
updatedTask: Task,
) => {
// Update the task reference with the saved task
this.task = updatedTask;
@ -94,13 +112,13 @@ export class TaskListItemComponent extends Component {
// Release the editor from the manager
TaskListItemComponent.editorManager?.releaseEditor(
this.task.id
this.task.id,
);
},
onMetadataEditFinished: (
targetEl: HTMLElement,
updatedTask: Task,
fieldType: string
fieldType: string,
) => {
// Update the task reference with the saved task
this.task = updatedTask;
@ -110,7 +128,7 @@ export class TaskListItemComponent extends Component {
// Release the editor from the manager
TaskListItemComponent.editorManager?.releaseEditor(
this.task.id
this.task.id,
);
},
useEmbeddedEditor: true, // Enable Obsidian's embedded editor
@ -118,7 +136,7 @@ export class TaskListItemComponent extends Component {
return TaskListItemComponent.editorManager!.getEditor(
this.task,
editorOptions
editorOptions,
);
}
@ -128,20 +146,69 @@ export class TaskListItemComponent extends Component {
private isCurrentlyEditing(): boolean {
return (
TaskListItemComponent.editorManager?.hasActiveEditor(
this.task.id
this.task.id,
) || false
);
}
onload() {
// Register selection change listener
if (this.selectionManager) {
this.registerEvent(
(this.app.workspace as Workspace).on(
"task-genius:selection-changed",
() => {
this.updateSelectionVisualState();
},
),
);
// Setup long press detection for mobile
if (Platform.isMobile) {
this.selectionManager.longPressDetector.startDetection(
this.element,
{
onLongPress: () => {
this.selectionManager?.enterSelectionMode();
this.handleMultiSelect();
},
},
);
}
}
this.registerDomEvent(this.element, "contextmenu", (event) => {
console.log("contextmenu", event, this.task);
// Check if we have multiple selections
if (
this.selectionManager &&
this.selectionManager.getSelectedCount() > 1
) {
// Show bulk operations menu
event.preventDefault();
event.stopPropagation();
showBulkOperationsMenu(
event,
this.app,
this.plugin,
this.selectionManager,
() => {
// Refresh view after operation
// The parent view should handle this via task updates
},
);
return;
}
if (this.onTaskContextMenu) {
this.onTaskContextMenu(event, this.task);
}
});
this.renderTaskItem();
this.updateSelectionVisualState();
}
private renderTaskItem() {
@ -162,7 +229,7 @@ export class TaskListItemComponent extends Component {
const checkbox = createTaskCheckbox(
this.task.status,
this.task,
el
el,
);
this.registerDomEvent(checkbox, "click", (event) => {
@ -177,7 +244,7 @@ export class TaskListItemComponent extends Component {
checkbox.dataset.task = "x";
}
});
}
},
);
this.element.appendChild(checkboxEl);
@ -237,7 +304,7 @@ export class TaskListItemComponent extends Component {
if (sanitizedPriority) {
classes.push(`priority-${sanitizedPriority}`);
}
const priorityEl = createDiv({cls: classes});
const priorityEl = createDiv({ cls: classes });
// Priority icon based on level
let icon = "•";
@ -248,7 +315,25 @@ export class TaskListItemComponent extends Component {
}
// Click handler to select task
this.registerDomEvent(this.element, "click", () => {
this.registerDomEvent(this.element, "click", (e) => {
// Check for multi-select with Shift key
if (this.selectionManager && e.shiftKey) {
e.stopPropagation();
this.handleMultiSelect();
return;
}
// Check if in selection mode
if (
this.selectionManager &&
this.selectionManager.isSelectionMode
) {
e.stopPropagation();
this.handleMultiSelect();
return;
}
// Normal single selection
if (this.onTaskSelected) {
this.onTaskSelected(this.task);
}
@ -262,7 +347,7 @@ export class TaskListItemComponent extends Component {
if (this.task.metadata.cancelledDate) {
this.renderDateMetadata(
"cancelled",
this.task.metadata.cancelledDate
this.task.metadata.cancelledDate,
);
}
@ -279,7 +364,7 @@ export class TaskListItemComponent extends Component {
if (this.task.metadata.scheduledDate) {
this.renderDateMetadata(
"scheduled",
this.task.metadata.scheduledDate
this.task.metadata.scheduledDate,
);
}
@ -297,7 +382,7 @@ export class TaskListItemComponent extends Component {
if (this.task.metadata.completedDate) {
this.renderDateMetadata(
"completed",
this.task.metadata.completedDate
this.task.metadata.completedDate,
);
}
@ -305,7 +390,7 @@ export class TaskListItemComponent extends Component {
if (this.task.metadata.createdDate) {
this.renderDateMetadata(
"created",
this.task.metadata.createdDate
this.task.metadata.createdDate,
);
}
}
@ -353,7 +438,7 @@ export class TaskListItemComponent extends Component {
| "completed"
| "cancelled"
| "created",
dateValue: number
dateValue: number,
) {
const dateEl = this.metadataEl.createEl("div", {
cls: ["task-date", `task-${type}-date`],
@ -399,10 +484,10 @@ export class TaskListItemComponent extends Component {
dateText = this.settings.useRelativeTimeForDate
? getRelativeTimeString(date)
: date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
year: "numeric",
month: "long",
day: "numeric",
});
}
if (cssClass) {
@ -435,7 +520,7 @@ export class TaskListItemComponent extends Component {
this.getInlineEditor().showMetadataEditor(
dateEl,
fieldType,
dateString
dateString,
);
}
}
@ -481,7 +566,7 @@ export class TaskListItemComponent extends Component {
this.getInlineEditor().showMetadataEditor(
projectEl,
"project",
this.task.metadata.project || ""
this.task.metadata.project || "",
);
}
});
@ -511,7 +596,7 @@ export class TaskListItemComponent extends Component {
this.getInlineEditor().showMetadataEditor(
tagsContainer,
"tags",
tagsString
tagsString,
);
}
});
@ -533,7 +618,7 @@ export class TaskListItemComponent extends Component {
this.getInlineEditor().showMetadataEditor(
recurrenceEl,
"recurrence",
this.task.metadata.recurrence || ""
this.task.metadata.recurrence || "",
);
}
});
@ -554,7 +639,7 @@ export class TaskListItemComponent extends Component {
this.getInlineEditor().showMetadataEditor(
onCompletionEl,
"onCompletion",
this.task.metadata.onCompletion || ""
this.task.metadata.onCompletion || "",
);
}
});
@ -566,7 +651,7 @@ export class TaskListItemComponent extends Component {
cls: "task-dependson",
});
dependsOnEl.textContent = `${this.task.metadata.dependsOn?.join(
", "
", ",
)}`;
// Make dependsOn clickable for editing only if inline editor is enabled
@ -577,7 +662,7 @@ export class TaskListItemComponent extends Component {
this.getInlineEditor().showMetadataEditor(
dependsOnEl,
"dependsOn",
this.task.metadata.dependsOn?.join(", ") || ""
this.task.metadata.dependsOn?.join(", ") || "",
);
}
});
@ -598,7 +683,7 @@ export class TaskListItemComponent extends Component {
this.getInlineEditor().showMetadataEditor(
idEl,
"id",
this.task.metadata.id || ""
this.task.metadata.id || "",
);
}
});
@ -618,7 +703,7 @@ export class TaskListItemComponent extends Component {
// Create the add metadata button
const addBtn = addButtonContainer.createEl("button", {
cls: "add-metadata-btn",
attr: {"aria-label": "Add metadata"},
attr: { "aria-label": "Add metadata" },
});
setIcon(addBtn, "plus");
@ -636,19 +721,19 @@ export class TaskListItemComponent extends Component {
const menu = new Menu();
const availableFields = [
{key: "project", label: "Project", icon: "folder"},
{key: "tags", label: "Tags", icon: "tag"},
{key: "context", label: "Context", icon: "at-sign"},
{key: "dueDate", label: "Due Date", icon: "calendar"},
{key: "startDate", label: "Start Date", icon: "play"},
{key: "scheduledDate", label: "Scheduled Date", icon: "clock"},
{key: "cancelledDate", label: "Cancelled Date", icon: "x"},
{key: "completedDate", label: "Completed Date", icon: "check"},
{key: "priority", label: "Priority", icon: "alert-triangle"},
{key: "recurrence", label: "Recurrence", icon: "repeat"},
{key: "onCompletion", label: "On Completion", icon: "flag"},
{key: "dependsOn", label: "Depends On", icon: "link"},
{key: "id", label: "Task ID", icon: "hash"},
{ key: "project", label: "Project", icon: "folder" },
{ key: "tags", label: "Tags", icon: "tag" },
{ key: "context", label: "Context", icon: "at-sign" },
{ key: "dueDate", label: "Due Date", icon: "calendar" },
{ key: "startDate", label: "Start Date", icon: "play" },
{ key: "scheduledDate", label: "Scheduled Date", icon: "clock" },
{ key: "cancelledDate", label: "Cancelled Date", icon: "x" },
{ key: "completedDate", label: "Completed Date", icon: "check" },
{ key: "priority", label: "Priority", icon: "alert-triangle" },
{ key: "recurrence", label: "Recurrence", icon: "repeat" },
{ key: "onCompletion", label: "On Completion", icon: "flag" },
{ key: "dependsOn", label: "Depends On", icon: "link" },
{ key: "id", label: "Task ID", icon: "hash" },
];
// Filter out fields that already have values
@ -695,7 +780,7 @@ export class TaskListItemComponent extends Component {
if (fieldsToShow.length === 0) {
menu.addItem((item) => {
item.setTitle(
"All metadata fields are already set"
"All metadata fields are already set",
).setDisabled(true);
});
} else {
@ -712,7 +797,7 @@ export class TaskListItemComponent extends Component {
editor.showMetadataEditor(
tempContainer,
field.key as any
field.key as any,
);
});
});
@ -747,13 +832,13 @@ export class TaskListItemComponent extends Component {
this.markdownRenderer = new MarkdownRendererComponent(
this.app,
this.contentEl,
this.task.filePath
this.task.filePath,
);
this.addChild(this.markdownRenderer);
// Render the markdown content - 使用最新的 originalMarkdown
this.markdownRenderer.render(
this.task.originalMarkdown || "\u200b"
this.task.originalMarkdown || "\u200b",
);
// Re-register the click event for editing after rendering
@ -780,11 +865,11 @@ export class TaskListItemComponent extends Component {
// If disabled, always use multi-line (traditional) layout
this.contentMetadataContainer.toggleClass(
"multi-line-content",
true
true,
);
this.contentMetadataContainer.toggleClass(
"single-line-content",
false
false,
);
return;
}
@ -804,11 +889,11 @@ export class TaskListItemComponent extends Component {
// Apply appropriate layout class using Obsidian's toggleClass method
this.contentMetadataContainer.toggleClass(
"multi-line-content",
isMultiLine
isMultiLine,
);
this.contentMetadataContainer.toggleClass(
"single-line-content",
!isMultiLine
!isMultiLine,
);
}
@ -896,6 +981,33 @@ export class TaskListItemComponent extends Component {
}
}
/**
* Handle multi-select toggle
*/
private handleMultiSelect(): void {
if (!this.selectionManager) return;
this.selectionManager.toggleSelection(this.task.id);
this.updateSelectionVisualState();
}
/**
* Update visual state based on selection
*/
private updateSelectionVisualState(): void {
if (!this.selectionManager) return;
const isSelected = this.selectionManager.isTaskSelected(this.task.id);
this.isTaskSelectedState = isSelected;
// Update visual state
if (isSelected) {
this.element.classList.add("task-item-selected");
} else {
this.element.classList.remove("task-item-selected");
}
}
onunload() {
// Release editor from manager if this task was being edited
if (

View file

@ -1,4 +1,12 @@
import { App, Component, setIcon, Menu, Keymap } from "obsidian";
import {
App,
Component,
setIcon,
Menu,
Keymap,
Platform,
Workspace,
} from "obsidian";
import { Task } from "@/types/task";
import "@/styles/tree-view.css";
import { MarkdownRendererComponent } from "@/components/ui/renderers/MarkdownRenderer";
@ -11,6 +19,8 @@ import { InlineEditor, InlineEditorOptions } from "./InlineEditor";
import { InlineEditorManager } from "./InlineEditorManager";
import { sanitizePriorityForClass } from "@/utils/task/priority-utils";
import { sortTasks } from "@/commands/sortTaskCommands";
import { TaskSelectionManager } from "@/components/features/task/selection/TaskSelectionManager";
import { showBulkOperationsMenu } from "./BulkOperationsMenu";
export class TaskTreeItemComponent extends Component {
public element: HTMLElement;
@ -41,6 +51,10 @@ export class TaskTreeItemComponent extends Component {
// Use shared editor manager instead of individual editors
private static editorManager: InlineEditorManager | null = null;
// Selection management
private selectionManager: TaskSelectionManager | null = null;
private isTaskSelectedState: boolean = false;
constructor(
task: Task,
viewMode: string,
@ -48,7 +62,8 @@ export class TaskTreeItemComponent extends Component {
indentLevel: number = 0,
private childTasks: Task[] = [],
taskMap: Map<string, Task>,
private plugin: TaskProgressBarPlugin
private plugin: TaskProgressBarPlugin,
selectionManager?: TaskSelectionManager,
) {
super();
this.task = task;
@ -56,11 +71,14 @@ export class TaskTreeItemComponent extends Component {
this.indentLevel = indentLevel;
this.taskMap = taskMap;
// Store selection manager reference
this.selectionManager = selectionManager || null;
// Initialize shared editor manager if not exists
if (!TaskTreeItemComponent.editorManager) {
TaskTreeItemComponent.editorManager = new InlineEditorManager(
this.app,
this.plugin
this.plugin,
);
}
}
@ -75,7 +93,7 @@ export class TaskTreeItemComponent extends Component {
try {
await this.onTaskUpdate(originalTask, updatedTask);
console.log(
"treeItem onTaskUpdate completed successfully"
"treeItem onTaskUpdate completed successfully",
);
// Don't update task reference here - let onContentEditFinished handle it
} catch (error) {
@ -88,7 +106,7 @@ export class TaskTreeItemComponent extends Component {
},
onContentEditFinished: (
targetEl: HTMLElement,
updatedTask: Task
updatedTask: Task,
) => {
// Update the task reference with the saved task
this.task = updatedTask;
@ -101,13 +119,13 @@ export class TaskTreeItemComponent extends Component {
// Release the editor from the manager
TaskTreeItemComponent.editorManager?.releaseEditor(
this.task.id
this.task.id,
);
},
onMetadataEditFinished: (
targetEl: HTMLElement,
updatedTask: Task,
fieldType: string
fieldType: string,
) => {
// Update the task reference with the saved task
this.task = updatedTask;
@ -117,7 +135,7 @@ export class TaskTreeItemComponent extends Component {
// Release the editor from the manager
TaskTreeItemComponent.editorManager?.releaseEditor(
this.task.id
this.task.id,
);
},
useEmbeddedEditor: true, // Enable Obsidian's embedded editor
@ -125,7 +143,7 @@ export class TaskTreeItemComponent extends Component {
return TaskTreeItemComponent.editorManager!.getEditor(
this.task,
editorOptions
editorOptions,
);
}
@ -135,7 +153,7 @@ export class TaskTreeItemComponent extends Component {
private isCurrentlyEditing(): boolean {
return (
TaskTreeItemComponent.editorManager?.hasActiveEditor(
this.task.id
this.task.id,
) || false
);
}
@ -149,9 +167,52 @@ export class TaskTreeItemComponent extends Component {
},
});
// Register selection change listener
if (this.selectionManager) {
this.registerEvent(
(this.app.workspace as Workspace).on(
"task-genius:selection-changed",
() => {
this.updateSelectionVisualState();
},
),
);
// Setup long press detection for mobile
if (Platform.isMobile) {
this.selectionManager.longPressDetector.startDetection(
this.element,
{
onLongPress: () => {
this.selectionManager?.enterSelectionMode();
this.handleMultiSelect();
},
},
);
}
}
this.registerDomEvent(this.element, "contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
// Check if we have multiple selections
if (
this.selectionManager &&
this.selectionManager.getSelectedCount() > 1
) {
showBulkOperationsMenu(
e,
this.app,
this.plugin,
this.selectionManager,
() => {
// Optionally refresh the view after bulk operation
},
);
return;
}
if (this.onTaskContextMenu) {
this.onTaskContextMenu(e, this.task);
}
@ -181,7 +242,7 @@ export class TaskTreeItemComponent extends Component {
this.parentContainer.contains(e.target as Node)
) {
const isCheckbox = (e.target as HTMLElement).classList.contains(
"task-checkbox"
"task-checkbox",
);
if (isCheckbox) {
@ -189,15 +250,36 @@ export class TaskTreeItemComponent extends Component {
this.toggleTaskCompletion();
} else if (
(e.target as HTMLElement).classList.contains(
"task-expand-toggle"
"task-expand-toggle",
)
) {
e.stopPropagation();
} else {
// Check for multi-select with Shift key
if (this.selectionManager && e.shiftKey) {
e.stopPropagation();
this.handleMultiSelect();
return;
}
// Check if in selection mode
if (
this.selectionManager &&
this.selectionManager.isSelectionMode
) {
e.stopPropagation();
this.handleMultiSelect();
return;
}
// Normal selection
this.selectTask();
}
}
});
// Update selection visual state on load
this.updateSelectionVisualState();
}
private renderTaskContent() {
@ -224,7 +306,7 @@ export class TaskTreeItemComponent extends Component {
});
setIcon(
this.toggleEl,
this.isExpanded ? "chevron-down" : "chevron-right"
this.isExpanded ? "chevron-down" : "chevron-right",
);
// Register toggle event
@ -243,7 +325,7 @@ export class TaskTreeItemComponent extends Component {
const checkbox = createTaskCheckbox(
this.task.status,
this.task,
el
el,
);
this.registerDomEvent(checkbox, "click", (event) => {
@ -258,7 +340,7 @@ export class TaskTreeItemComponent extends Component {
checkbox.dataset.task = "x";
}
});
}
},
);
const taskItemContainer = this.parentContainer.createDiv({
@ -290,7 +372,7 @@ export class TaskTreeItemComponent extends Component {
// Priority indicator if available
if (this.task.metadata.priority) {
const sanitizedPriority = sanitizePriorityForClass(
this.task.metadata.priority
this.task.metadata.priority,
);
const classes = ["task-priority"];
if (sanitizedPriority) {
@ -315,7 +397,7 @@ export class TaskTreeItemComponent extends Component {
this.renderDateMetadata(
metadataEl,
"cancelled",
this.task.metadata.cancelledDate
this.task.metadata.cancelledDate,
);
}
@ -326,7 +408,7 @@ export class TaskTreeItemComponent extends Component {
this.renderDateMetadata(
metadataEl,
"due",
this.task.metadata.dueDate
this.task.metadata.dueDate,
);
}
@ -335,7 +417,7 @@ export class TaskTreeItemComponent extends Component {
this.renderDateMetadata(
metadataEl,
"scheduled",
this.task.metadata.scheduledDate
this.task.metadata.scheduledDate,
);
}
@ -344,7 +426,7 @@ export class TaskTreeItemComponent extends Component {
this.renderDateMetadata(
metadataEl,
"start",
this.task.metadata.startDate
this.task.metadata.startDate,
);
}
@ -358,7 +440,7 @@ export class TaskTreeItemComponent extends Component {
this.renderDateMetadata(
metadataEl,
"completed",
this.task.metadata.completedDate
this.task.metadata.completedDate,
);
}
@ -367,7 +449,7 @@ export class TaskTreeItemComponent extends Component {
this.renderDateMetadata(
metadataEl,
"created",
this.task.metadata.createdDate
this.task.metadata.createdDate,
);
}
}
@ -416,7 +498,7 @@ export class TaskTreeItemComponent extends Component {
| "completed"
| "cancelled"
| "created",
dateValue: number
dateValue: number,
) {
const dateEl = metadataEl.createEl("div", {
cls: ["task-date", `task-${type}-date`],
@ -465,7 +547,7 @@ export class TaskTreeItemComponent extends Component {
year: "numeric",
month: "long",
day: "numeric",
});
});
}
if (cssClass) {
@ -486,20 +568,20 @@ export class TaskTreeItemComponent extends Component {
type === "due"
? "dueDate"
: type === "scheduled"
? "scheduledDate"
: type === "start"
? "startDate"
: type === "cancelled"
? "cancelledDate"
: type === "completed"
? "completedDate"
: null;
? "scheduledDate"
: type === "start"
? "startDate"
: type === "cancelled"
? "cancelledDate"
: type === "completed"
? "completedDate"
: null;
if (fieldType) {
editor.showMetadataEditor(
dateEl,
fieldType,
dateString
dateString,
);
}
}
@ -546,7 +628,7 @@ export class TaskTreeItemComponent extends Component {
editor.showMetadataEditor(
projectEl,
"project",
this.task.metadata.project || ""
this.task.metadata.project || "",
);
}
});
@ -581,7 +663,7 @@ export class TaskTreeItemComponent extends Component {
editor.showMetadataEditor(
tagsContainer,
"tags",
tagsString
tagsString,
);
}
});
@ -604,7 +686,7 @@ export class TaskTreeItemComponent extends Component {
editor.showMetadataEditor(
recurrenceEl,
"recurrence",
this.task.metadata.recurrence || ""
this.task.metadata.recurrence || "",
);
}
});
@ -626,7 +708,7 @@ export class TaskTreeItemComponent extends Component {
editor.showMetadataEditor(
onCompletionEl,
"onCompletion",
this.task.metadata.onCompletion || ""
this.task.metadata.onCompletion || "",
);
}
});
@ -638,7 +720,7 @@ export class TaskTreeItemComponent extends Component {
cls: "task-dependson",
});
dependsOnEl.textContent = `${this.task.metadata.dependsOn?.join(
", "
", ",
)}`;
// Make dependsOn clickable for editing only if inline editor is enabled
@ -650,7 +732,7 @@ export class TaskTreeItemComponent extends Component {
editor.showMetadataEditor(
dependsOnEl,
"dependsOn",
this.task.metadata.dependsOn?.join(", ") || ""
this.task.metadata.dependsOn?.join(", ") || "",
);
}
});
@ -672,7 +754,7 @@ export class TaskTreeItemComponent extends Component {
editor.showMetadataEditor(
idEl,
"id",
this.task.metadata.id || ""
this.task.metadata.id || "",
);
}
});
@ -769,7 +851,7 @@ export class TaskTreeItemComponent extends Component {
if (fieldsToShow.length === 0) {
menu.addItem((item) => {
item.setTitle(
"All metadata fields are already set"
"All metadata fields are already set",
).setDisabled(true);
});
} else {
@ -786,7 +868,7 @@ export class TaskTreeItemComponent extends Component {
editor.showMetadataEditor(
tempContainer,
field.key as any
field.key as any,
);
});
});
@ -819,7 +901,7 @@ export class TaskTreeItemComponent extends Component {
this.markdownRenderer = new MarkdownRendererComponent(
this.app,
this.contentEl,
this.task.filePath
this.task.filePath,
);
this.addChild(this.markdownRenderer);
@ -849,11 +931,11 @@ export class TaskTreeItemComponent extends Component {
// If disabled, always use multi-line (traditional) layout
this.contentMetadataContainer.toggleClass(
"multi-line-content",
true
true,
);
this.contentMetadataContainer.toggleClass(
"single-line-content",
false
false,
);
return;
}
@ -873,11 +955,11 @@ export class TaskTreeItemComponent extends Component {
// Apply appropriate layout class using Obsidian's toggleClass method
this.contentMetadataContainer.toggleClass(
"multi-line-content",
isMultiLine
isMultiLine,
);
this.contentMetadataContainer.toggleClass(
"single-line-content",
!isMultiLine
!isMultiLine,
);
}
@ -892,7 +974,10 @@ export class TaskTreeItemComponent extends Component {
// Open task in file
e.stopPropagation();
await this.openTaskInFile();
} else if (this.plugin.settings.enableInlineEditor && !this.isCurrentlyEditing()) {
} else if (
this.plugin.settings.enableInlineEditor &&
!this.isCurrentlyEditing()
) {
// Only stop propagation if we're actually going to show the editor
e.stopPropagation();
// Show inline editor only if enabled
@ -926,7 +1011,7 @@ export class TaskTreeItemComponent extends Component {
// Get view configuration to check if we should hide completed and abandoned tasks
const viewConfig = getViewSettingOrDefault(
this.plugin,
this.viewMode as ViewMode
this.viewMode as ViewMode,
);
const abandonedStatus =
this.plugin.settings.taskStatuses.abandoned.split("|");
@ -950,7 +1035,7 @@ export class TaskTreeItemComponent extends Component {
tasksToRender = sortTasks(
[...tasksToRender],
childSortCriteria,
this.plugin.settings
this.plugin.settings,
);
} else {
// Default sorting: incomplete first, then priority (high->low), due date (earlier->later), content; tie-break by filePath->line
@ -974,7 +1059,7 @@ export class TaskTreeItemComponent extends Component {
});
const contentCmp = collator.compare(
a.content ?? "",
b.content ?? ""
b.content ?? "",
);
if (contentCmp !== 0) return contentCmp;
const fp = (a.filePath || "").localeCompare(b.filePath || "");
@ -1000,7 +1085,8 @@ export class TaskTreeItemComponent extends Component {
this.indentLevel + 1,
grandchildren, // Pass the correctly found grandchildren
this.taskMap, // Pass the map down recursively
this.plugin // Pass the plugin down
this.plugin, // Pass the plugin down
this.selectionManager || undefined, // Pass selection manager down
);
// Pass up events
@ -1090,7 +1176,7 @@ export class TaskTreeItemComponent extends Component {
if (this.toggleEl instanceof HTMLElement) {
setIcon(
this.toggleEl,
this.isExpanded ? "chevron-down" : "chevron-right"
this.isExpanded ? "chevron-down" : "chevron-right",
);
}
@ -1152,7 +1238,7 @@ export class TaskTreeItemComponent extends Component {
) {
// Re-render metadata
const metadataEl = this.parentContainer.querySelector(
".task-metadata"
".task-metadata",
) as HTMLElement;
if (metadataEl) {
this.renderMetadata(metadataEl);
@ -1234,26 +1320,26 @@ export class TaskTreeItemComponent extends Component {
if (elementToToggle) {
elementToToggle.classList.toggle(
"is-selected",
this.isSelected
this.isSelected,
);
// Also ensure the parent container reflects selection if separate element
if (this.parentContainer) {
this.parentContainer.classList.toggle(
"selected",
this.isSelected
this.isSelected,
);
}
} else {
console.warn(
"Could not find element to toggle selection class for task:",
this.task.id
this.task.id,
);
}
}
// Recursively update children
this.childComponents.forEach((child) =>
child.updateSelectionVisuals(selectedId)
child.updateSelectionVisuals(selectedId),
);
}
@ -1265,7 +1351,7 @@ export class TaskTreeItemComponent extends Component {
if (this.toggleEl instanceof HTMLElement) {
setIcon(
this.toggleEl,
this.isExpanded ? "chevron-down" : "chevron-right"
this.isExpanded ? "chevron-down" : "chevron-right",
);
}
@ -1276,6 +1362,35 @@ export class TaskTreeItemComponent extends Component {
}
}
/**
* Handle multi-select toggle
*/
private handleMultiSelect(): void {
if (!this.selectionManager) return;
this.selectionManager.toggleSelection(this.task.id);
this.updateSelectionVisualState();
}
/**
* Update visual state based on selection
*/
private updateSelectionVisualState(): void {
if (!this.selectionManager) return;
const isSelected = this.selectionManager.isTaskSelected(this.task.id);
this.isTaskSelectedState = isSelected;
// Update visual state
if (isSelected) {
this.element?.classList.add("task-item-selected");
this.parentContainer?.classList.add("task-item-selected");
} else {
this.element?.classList.remove("task-item-selected");
this.parentContainer?.classList.remove("task-item-selected");
}
}
onunload() {
// Release editor from manager if this task was being edited
if (

View file

@ -24,6 +24,7 @@ import { Events, emit } from "../events/Events";
import { CanvasTaskUpdater } from "@/parsers/canvas-task-updater";
import { rrulestr } from "rrule";
import { EMOJI_TAG_REGEX, TOKEN_CONTEXT_REGEX } from "@/common/regex-define";
import { BulkOperationResult } from "@/types/selection";
/**
* Arguments for creating a task
@ -81,6 +82,7 @@ export interface BatchCreateSubtasksArgs {
export class WriteAPI {
canvasTaskUpdater: CanvasTaskUpdater;
private writeQueue: Promise<void>;
constructor(
private app: App,
@ -90,6 +92,20 @@ export class WriteAPI {
private getTaskById: (id: string) => Promise<Task | null> | Task | null,
) {
this.canvasTaskUpdater = new CanvasTaskUpdater(vault, plugin);
this.writeQueue = Promise.resolve();
}
private enqueueWrite<T>(operation: () => Promise<T>): Promise<T> {
const run = this.writeQueue
.catch(() => undefined)
.then(operation);
this.writeQueue = run.then(
() => undefined,
() => undefined,
);
return run;
}
/**
@ -99,6 +115,16 @@ export class WriteAPI {
taskId: string;
status?: string;
completed?: boolean;
}): Promise<{ success: boolean; task?: Task; error?: string }> {
return this.enqueueWrite(() =>
this.performUpdateTaskStatus(args),
);
}
private async performUpdateTaskStatus(args: {
taskId: string;
status?: string;
completed?: boolean;
}): Promise<{ success: boolean; task?: Task; error?: string }> {
try {
const task = await Promise.resolve(this.getTaskById(args.taskId));
@ -286,6 +312,12 @@ export class WriteAPI {
*/
async updateTask(
args: UpdateTaskArgs,
): Promise<{ success: boolean; task?: Task; error?: string }> {
return this.enqueueWrite(() => this.performUpdateTask(args));
}
private async performUpdateTask(
args: UpdateTaskArgs,
): Promise<{ success: boolean; task?: Task; error?: string }> {
try {
const originalTask = await Promise.resolve(
@ -778,6 +810,38 @@ export class WriteAPI {
}
}
async updateTasksSequentially(
argsList: UpdateTaskArgs[],
): Promise<BulkOperationResult> {
return this.enqueueWrite(async () => {
const summary: BulkOperationResult = {
successCount: 0,
failCount: 0,
errors: [],
totalCount: argsList.length,
};
for (const args of argsList) {
const originalTask = await Promise.resolve(
this.getTaskById(args.taskId),
);
const updateResult = await this.performUpdateTask(args);
if (updateResult.success) {
summary.successCount++;
} else {
summary.failCount++;
summary.errors.push({
taskId: args.taskId,
taskContent: originalTask?.content || "",
error: updateResult.error || "Unknown error",
});
}
}
return summary;
});
}
/**
* Update a file-source task (modifies file itself, not task content)
*/

View file

@ -0,0 +1,254 @@
/* Task Multi-Selection Styles */
/* Selection mode indicator overlay */
.task-view.selection-mode {
position: relative;
}
.task-view.selection-mode::before {
content: "";
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
background: rgba(var(--interactive-accent-rgb), 0.03);
z-index: 0;
}
/* Selected task item styling */
.task-item.task-item-selected {
background-color: rgba(var(--interactive-accent-rgb), 0.15) !important;
border-left: 3px solid var(--interactive-accent);
padding-left: 13px; /* Compensate for border */
position: relative;
}
/* Selection count badge */
.task-selection-badge {
position: fixed;
bottom: 20px;
right: 20px;
padding: 8px 16px;
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border-radius: 16px;
font-weight: 600;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
z-index: 1000;
display: flex;
align-items: center;
gap: 8px;
animation: slideInUp 0.2s ease-out;
}
@keyframes slideInUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.task-selection-badge .selection-count {
font-weight: bold;
}
/* Long press feedback on mobile */
@media (pointer: coarse) {
.task-item.long-press-active {
background-color: rgba(var(--interactive-accent-rgb), 0.1);
transform: scale(0.98);
transition: all 0.2s ease;
}
}
/* Hover effect when in selection mode */
.task-view.selection-mode .task-item:not(.task-item-selected):hover {
background-color: rgba(var(--interactive-accent-rgb), 0.08);
border-left: 2px solid rgba(var(--interactive-accent-rgb), 0.5);
padding-left: 14px;
}
/* Shift key indicator (optional visual feedback) */
.task-view.shift-key-active .task-item {
cursor: crosshair;
}
/* Bulk operations menu styling */
.task-bulk-operations-menu {
min-width: 200px;
}
.task-bulk-operations-menu .menu-item {
display: flex;
align-items: center;
gap: 8px;
}
.task-bulk-operations-menu .menu-item-icon {
opacity: 0.7;
}
/* Selection mode exit button */
.task-selection-exit-hint {
position: fixed;
top: 60px;
right: 20px;
padding: 6px 12px;
background-color: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
font-size: 12px;
color: var(--text-muted);
z-index: 999;
display: flex;
align-items: center;
gap: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.task-selection-exit-hint kbd {
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
padding: 2px 6px;
font-family: var(--font-monospace);
font-size: 11px;
}
/* Tree view selection styles */
.tree-item.task-item-selected {
background-color: rgba(var(--interactive-accent-rgb), 0.15) !important;
border-left: 3px solid var(--interactive-accent);
}
.tree-item.task-item-selected .tree-item-inner {
font-weight: 500;
}
/* Disabled state for non-selectable items */
.task-item.selection-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.task-item.selection-disabled:hover {
background-color: transparent;
}
/* Selection checkbox visual (if adding explicit checkboxes) */
.task-item-selection-checkbox {
width: 18px;
height: 18px;
border: 2px solid var(--text-muted);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 8px;
flex-shrink: 0;
transition: all 0.2s ease;
}
.task-item-selected .task-item-selection-checkbox {
background-color: var(--interactive-accent);
border-color: var(--interactive-accent);
}
.task-item-selected .task-item-selection-checkbox::after {
content: "✓";
color: var(--text-on-accent);
font-size: 12px;
font-weight: bold;
}
/* Fade in/out animations for selection mode */
.task-selection-mode-enter {
animation: fadeIn 0.2s ease-out;
}
.task-selection-mode-exit {
animation: fadeOut 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* Mobile-specific adjustments */
@media (max-width: 768px) {
.task-selection-badge {
bottom: 70px; /* Account for mobile bottom nav */
}
.task-item.task-item-selected {
border-left-width: 4px; /* More prominent on mobile */
padding-left: 12px;
}
.task-selection-exit-hint {
display: none; /* Hide ESC hint on mobile */
}
}
/* Dark theme adjustments */
.theme-dark .task-item.task-item-selected {
background-color: rgba(var(--interactive-accent-rgb), 0.2) !important;
}
.theme-dark .task-selection-badge {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
}
/* Light theme adjustments */
.theme-light .task-item.task-item-selected {
background-color: rgba(var(--interactive-accent-rgb), 0.12) !important;
}
.theme-light .task-selection-badge {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
/* Compact mode adjustments */
.task-view.compact-mode .task-item.task-item-selected {
padding-top: 4px;
padding-bottom: 4px;
}
/* Accessibility: High contrast mode */
@media (prefers-contrast: high) {
.task-item.task-item-selected {
border-left-width: 4px;
background-color: rgba(var(--interactive-accent-rgb), 0.25) !important;
}
}
/* Reduce motion for accessibility */
@media (prefers-reduced-motion: reduce) {
.task-item.task-item-selected,
.task-selection-badge,
.task-selection-exit-hint {
animation: none;
transition: none;
}
}

109
src/types/selection.d.ts vendored Normal file
View file

@ -0,0 +1,109 @@
/**
* Task selection and bulk operations type definitions
*/
import { Task } from "./task";
/**
* Bulk operation types
*/
export enum BulkOperationType {
STATUS_CHANGE = "status_change",
PROJECT_MOVE = "project_move",
PRIORITY_SET = "priority_set",
DATE_SET = "date_set",
DELETE = "delete",
TAG_ADD = "tag_add",
TAG_REMOVE = "tag_remove",
}
/**
* Date field types for bulk date operations
*/
export type DateFieldType =
| "dueDate"
| "startDate"
| "scheduledDate"
| "completedDate"
| "cancelledDate"
| "createdDate";
/**
* Selection state interface
*/
export interface SelectionState {
/** Set of selected task IDs */
selectedTaskIds: Set<string>;
/** Whether selection mode is active */
isSelectionMode: boolean;
/** Timestamp when selection mode was entered */
selectionModeStartTime?: number;
}
/**
* Bulk operation result
*/
export interface BulkOperationResult {
/** Number of successful operations */
successCount: number;
/** Number of failed operations */
failCount: number;
/** Error messages for failed operations */
errors: Array<{
taskId: string;
taskContent: string;
error: string;
}>;
/** Total operations attempted */
totalCount: number;
}
/**
* Long press detector options
*/
export interface LongPressOptions {
/** Detection threshold in milliseconds (default: 500ms) */
threshold?: number;
/** Movement tolerance in pixels (default: 10px) */
moveTolerance?: number;
/** Callback when long press is detected */
onLongPress: () => void;
/** Callback when long press starts (optional) */
onLongPressStart?: () => void;
/** Callback when long press is cancelled (optional) */
onLongPressCancel?: () => void;
}
/**
* Bulk operation configuration
*/
export interface BulkOperationConfig {
/** Operation type */
type: BulkOperationType;
/** Target value (e.g., new status, project name, priority level) */
value?: any;
/** Additional metadata for the operation */
metadata?: Record<string, any>;
}
/**
* Selection event data
*/
export interface SelectionEventData {
/** Selected task IDs */
selectedTaskIds: string[];
/** Selection mode state */
isSelectionMode: boolean;
/** Number of selected tasks */
count: number;
}
/**
* Selection mode change event data
*/
export interface SelectionModeChangeEventData {
/** New selection mode state */
isSelectionMode: boolean;
/** Reason for mode change */
reason?: "user_action" | "view_change" | "operation_complete" | "escape";
}

File diff suppressed because one or more lines are too long