mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
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:
parent
dee954193f
commit
852f527fd0
11 changed files with 2307 additions and 114 deletions
|
|
@ -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 })),
|
||||
};
|
||||
|
|
|
|||
161
src/components/features/task/selection/LongPressDetector.ts
Normal file
161
src/components/features/task/selection/LongPressDetector.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
228
src/components/features/task/selection/TaskSelectionManager.ts
Normal file
228
src/components/features/task/selection/TaskSelectionManager.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
905
src/components/features/task/view/BulkOperationsMenu.ts
Normal file
905
src/components/features/task/view/BulkOperationsMenu.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
254
src/styles/task-selection.css
Normal file
254
src/styles/task-selection.css
Normal 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
109
src/types/selection.d.ts
vendored
Normal 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";
|
||||
}
|
||||
235
styles.css
235
styles.css
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue