mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
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
1412 lines
37 KiB
TypeScript
1412 lines
37 KiB
TypeScript
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";
|
|
import { createTaskCheckbox } from "./details";
|
|
import { getViewSettingOrDefault, ViewMode } from "@/common/setting-definition";
|
|
import { getRelativeTimeString } from "@/utils/date/date-formatter";
|
|
import { t } from "@/translations/helper";
|
|
import TaskProgressBarPlugin from "@/index";
|
|
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;
|
|
private task: Task;
|
|
private isSelected: boolean = false;
|
|
private isExpanded: boolean = true;
|
|
private viewMode: string;
|
|
private indentLevel: number = 0;
|
|
private parentContainer: HTMLElement;
|
|
private childrenContainer: HTMLElement;
|
|
private childComponents: TaskTreeItemComponent[] = [];
|
|
|
|
private toggleEl: HTMLElement;
|
|
|
|
// Events
|
|
public onTaskSelected: (task: Task) => void;
|
|
public onTaskCompleted: (task: Task) => void;
|
|
public onTaskUpdate: (task: Task, updatedTask: Task) => Promise<void>;
|
|
public onToggleExpand: (taskId: string, isExpanded: boolean) => void;
|
|
|
|
public onTaskContextMenu: (event: MouseEvent, task: Task) => void;
|
|
|
|
private markdownRenderer: MarkdownRendererComponent;
|
|
private contentEl: HTMLElement;
|
|
private contentMetadataContainer: HTMLElement;
|
|
private taskMap: Map<string, Task>;
|
|
|
|
// 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,
|
|
private app: App,
|
|
indentLevel: number = 0,
|
|
private childTasks: Task[] = [],
|
|
taskMap: Map<string, Task>,
|
|
private plugin: TaskProgressBarPlugin,
|
|
selectionManager?: TaskSelectionManager,
|
|
) {
|
|
super();
|
|
this.task = task;
|
|
this.viewMode = viewMode;
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the inline editor from the shared manager when needed
|
|
*/
|
|
private getInlineEditor(): InlineEditor {
|
|
const editorOptions: InlineEditorOptions = {
|
|
onTaskUpdate: async (originalTask: Task, updatedTask: Task) => {
|
|
if (this.onTaskUpdate) {
|
|
try {
|
|
await this.onTaskUpdate(originalTask, updatedTask);
|
|
console.log(
|
|
"treeItem onTaskUpdate completed successfully",
|
|
);
|
|
// Don't update task reference here - let onContentEditFinished handle it
|
|
} catch (error) {
|
|
console.error("Error in treeItem onTaskUpdate:", error);
|
|
throw error; // Re-throw to let the InlineEditor handle it
|
|
}
|
|
} else {
|
|
console.warn("No onTaskUpdate callback available");
|
|
}
|
|
},
|
|
onContentEditFinished: (
|
|
targetEl: HTMLElement,
|
|
updatedTask: Task,
|
|
) => {
|
|
// Update the task reference with the saved task
|
|
this.task = updatedTask;
|
|
|
|
// Re-render the markdown content after editing is finished
|
|
this.renderMarkdown();
|
|
|
|
// Now it's safe to update the full display
|
|
this.updateTaskDisplay();
|
|
|
|
// Release the editor from the manager
|
|
TaskTreeItemComponent.editorManager?.releaseEditor(
|
|
this.task.id,
|
|
);
|
|
},
|
|
onMetadataEditFinished: (
|
|
targetEl: HTMLElement,
|
|
updatedTask: Task,
|
|
fieldType: string,
|
|
) => {
|
|
// Update the task reference with the saved task
|
|
this.task = updatedTask;
|
|
|
|
// Update the task display to reflect metadata changes
|
|
this.updateTaskDisplay();
|
|
|
|
// Release the editor from the manager
|
|
TaskTreeItemComponent.editorManager?.releaseEditor(
|
|
this.task.id,
|
|
);
|
|
},
|
|
useEmbeddedEditor: true, // Enable Obsidian's embedded editor
|
|
};
|
|
|
|
return TaskTreeItemComponent.editorManager!.getEditor(
|
|
this.task,
|
|
editorOptions,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if this task is currently being edited
|
|
*/
|
|
private isCurrentlyEditing(): boolean {
|
|
return (
|
|
TaskTreeItemComponent.editorManager?.hasActiveEditor(
|
|
this.task.id,
|
|
) || false
|
|
);
|
|
}
|
|
|
|
onload() {
|
|
// Create task item container
|
|
this.element = createDiv({
|
|
cls: ["task-item", "tree-task-item"],
|
|
attr: {
|
|
"data-task-id": this.task.id,
|
|
},
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
|
|
// Create parent container
|
|
this.parentContainer = this.element.createDiv({
|
|
cls: "task-parent-container",
|
|
});
|
|
|
|
// Create task content
|
|
this.renderTaskContent();
|
|
|
|
// Create container for child tasks
|
|
this.childrenContainer = this.element.createDiv({
|
|
cls: "task-children-container",
|
|
});
|
|
|
|
// Render child tasks
|
|
this.renderChildTasks();
|
|
|
|
// Register click handler for selection
|
|
this.registerDomEvent(this.parentContainer, "click", (e) => {
|
|
// Only trigger if clicking on the task itself, not children
|
|
if (
|
|
e.target === this.parentContainer ||
|
|
this.parentContainer.contains(e.target as Node)
|
|
) {
|
|
const isCheckbox = (e.target as HTMLElement).classList.contains(
|
|
"task-checkbox",
|
|
);
|
|
|
|
if (isCheckbox) {
|
|
e.stopPropagation();
|
|
this.toggleTaskCompletion();
|
|
} else if (
|
|
(e.target as HTMLElement).classList.contains(
|
|
"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() {
|
|
// Clear existing content
|
|
this.parentContainer.empty();
|
|
this.parentContainer.classList.toggle("completed", this.task.completed);
|
|
this.parentContainer.classList.toggle("selected", this.isSelected);
|
|
|
|
// Indentation based on level
|
|
if (this.indentLevel > 0) {
|
|
const indentEl = this.parentContainer.createDiv({
|
|
cls: "task-indent",
|
|
});
|
|
indentEl.style.width = `${this.indentLevel * 30}px`;
|
|
}
|
|
|
|
// Expand/collapse toggle for tasks with children
|
|
if (
|
|
this.task.metadata.children &&
|
|
this.task.metadata.children.length > 0
|
|
) {
|
|
this.toggleEl = this.parentContainer.createDiv({
|
|
cls: "task-expand-toggle",
|
|
});
|
|
setIcon(
|
|
this.toggleEl,
|
|
this.isExpanded ? "chevron-down" : "chevron-right",
|
|
);
|
|
|
|
// Register toggle event
|
|
this.registerDomEvent(this.toggleEl, "click", (e) => {
|
|
e.stopPropagation();
|
|
this.toggleExpand();
|
|
});
|
|
}
|
|
|
|
// Checkbox
|
|
const checkboxEl = this.parentContainer.createDiv(
|
|
{
|
|
cls: "task-checkbox",
|
|
},
|
|
(el) => {
|
|
const checkbox = createTaskCheckbox(
|
|
this.task.status,
|
|
this.task,
|
|
el,
|
|
);
|
|
|
|
this.registerDomEvent(checkbox, "click", (event) => {
|
|
event.stopPropagation();
|
|
|
|
if (this.onTaskCompleted) {
|
|
this.onTaskCompleted(this.task);
|
|
}
|
|
|
|
if (this.task.status === " ") {
|
|
checkbox.checked = true;
|
|
checkbox.dataset.task = "x";
|
|
}
|
|
});
|
|
},
|
|
);
|
|
|
|
const taskItemContainer = this.parentContainer.createDiv({
|
|
cls: "task-item-container",
|
|
});
|
|
|
|
// Create content-metadata container for dynamic layout
|
|
this.contentMetadataContainer = taskItemContainer.createDiv({
|
|
cls: "task-content-metadata-container",
|
|
});
|
|
|
|
// Task content with markdown rendering
|
|
this.contentEl = this.contentMetadataContainer.createDiv({
|
|
cls: "task-item-content",
|
|
});
|
|
|
|
// Make content clickable for editing - only create editor when clicked
|
|
this.registerContentClickHandler();
|
|
|
|
this.renderMarkdown();
|
|
|
|
// Metadata container
|
|
const metadataEl = this.contentMetadataContainer.createDiv({
|
|
cls: "task-metadata",
|
|
});
|
|
|
|
this.renderMetadata(metadataEl);
|
|
|
|
// Priority indicator if available
|
|
if (this.task.metadata.priority) {
|
|
const sanitizedPriority = sanitizePriorityForClass(
|
|
this.task.metadata.priority,
|
|
);
|
|
const classes = ["task-priority"];
|
|
if (sanitizedPriority) {
|
|
classes.push(`priority-${sanitizedPriority}`);
|
|
}
|
|
const priorityEl = createDiv({ cls: classes });
|
|
|
|
// Priority icon based on level
|
|
let icon = "•";
|
|
icon = "!".repeat(this.task.metadata.priority);
|
|
|
|
priorityEl.textContent = icon;
|
|
this.parentContainer.appendChild(priorityEl);
|
|
}
|
|
}
|
|
|
|
private renderMetadata(metadataEl: HTMLElement) {
|
|
metadataEl.empty();
|
|
|
|
// For cancelled tasks, show cancelled date (independent of completion status)
|
|
if (this.task.metadata.cancelledDate) {
|
|
this.renderDateMetadata(
|
|
metadataEl,
|
|
"cancelled",
|
|
this.task.metadata.cancelledDate,
|
|
);
|
|
}
|
|
|
|
// Display dates based on task completion status
|
|
if (!this.task.completed) {
|
|
// Due date if available
|
|
if (this.task.metadata.dueDate) {
|
|
this.renderDateMetadata(
|
|
metadataEl,
|
|
"due",
|
|
this.task.metadata.dueDate,
|
|
);
|
|
}
|
|
|
|
// Scheduled date if available
|
|
if (this.task.metadata.scheduledDate) {
|
|
this.renderDateMetadata(
|
|
metadataEl,
|
|
"scheduled",
|
|
this.task.metadata.scheduledDate,
|
|
);
|
|
}
|
|
|
|
// Start date if available
|
|
if (this.task.metadata.startDate) {
|
|
this.renderDateMetadata(
|
|
metadataEl,
|
|
"start",
|
|
this.task.metadata.startDate,
|
|
);
|
|
}
|
|
|
|
// Recurrence if available
|
|
if (this.task.metadata.recurrence) {
|
|
this.renderRecurrenceMetadata(metadataEl);
|
|
}
|
|
} else {
|
|
// For completed tasks, show completion date
|
|
if (this.task.metadata.completedDate) {
|
|
this.renderDateMetadata(
|
|
metadataEl,
|
|
"completed",
|
|
this.task.metadata.completedDate,
|
|
);
|
|
}
|
|
|
|
// Created date if available
|
|
if (this.task.metadata.createdDate) {
|
|
this.renderDateMetadata(
|
|
metadataEl,
|
|
"created",
|
|
this.task.metadata.createdDate,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Project badge if available and not in project view
|
|
if (
|
|
(this.task.metadata.project || this.task.metadata.tgProject) &&
|
|
this.viewMode !== "projects"
|
|
) {
|
|
this.renderProjectMetadata(metadataEl);
|
|
}
|
|
|
|
// Tags if available
|
|
if (this.task.metadata.tags && this.task.metadata.tags.length > 0) {
|
|
this.renderTagsMetadata(metadataEl);
|
|
}
|
|
|
|
// OnCompletion if available
|
|
if (this.task.metadata.onCompletion) {
|
|
this.renderOnCompletionMetadata(metadataEl);
|
|
}
|
|
|
|
// DependsOn if available
|
|
if (
|
|
this.task.metadata.dependsOn &&
|
|
this.task.metadata.dependsOn.length > 0
|
|
) {
|
|
this.renderDependsOnMetadata(metadataEl);
|
|
}
|
|
|
|
// ID if available
|
|
if (this.task.metadata.id) {
|
|
this.renderIdMetadata(metadataEl);
|
|
}
|
|
|
|
// Add metadata button for adding new metadata
|
|
this.renderAddMetadataButton(metadataEl);
|
|
}
|
|
|
|
private renderDateMetadata(
|
|
metadataEl: HTMLElement,
|
|
type:
|
|
| "due"
|
|
| "scheduled"
|
|
| "start"
|
|
| "completed"
|
|
| "cancelled"
|
|
| "created",
|
|
dateValue: number,
|
|
) {
|
|
const dateEl = metadataEl.createEl("div", {
|
|
cls: ["task-date", `task-${type}-date`],
|
|
});
|
|
|
|
const date = new Date(dateValue);
|
|
let dateText = "";
|
|
let cssClass = "";
|
|
|
|
if (type === "due") {
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
const tomorrow = new Date(today);
|
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
|
|
// Format date
|
|
if (date.getTime() < today.getTime()) {
|
|
dateText =
|
|
t("Overdue") +
|
|
(this.plugin.settings?.useRelativeTimeForDate
|
|
? " | " + getRelativeTimeString(date)
|
|
: "");
|
|
cssClass = "task-overdue";
|
|
} else if (date.getTime() === today.getTime()) {
|
|
dateText = this.plugin.settings?.useRelativeTimeForDate
|
|
? getRelativeTimeString(date) || "Today"
|
|
: "Today";
|
|
cssClass = "task-due-today";
|
|
} else if (date.getTime() === tomorrow.getTime()) {
|
|
dateText = this.plugin.settings?.useRelativeTimeForDate
|
|
? getRelativeTimeString(date) || "Tomorrow"
|
|
: "Tomorrow";
|
|
cssClass = "task-due-tomorrow";
|
|
} else {
|
|
dateText = date.toLocaleDateString("en-US", {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
} else {
|
|
dateText = this.plugin.settings?.useRelativeTimeForDate
|
|
? getRelativeTimeString(date)
|
|
: date.toLocaleDateString("en-US", {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
if (cssClass) {
|
|
dateEl.classList.add(cssClass);
|
|
}
|
|
|
|
dateEl.textContent = dateText;
|
|
dateEl.setAttribute("aria-label", date.toLocaleDateString());
|
|
|
|
// Make date clickable for editing only if inline editor is enabled
|
|
if (this.plugin.settings.enableInlineEditor) {
|
|
this.registerDomEvent(dateEl, "click", (e) => {
|
|
e.stopPropagation();
|
|
if (!this.isCurrentlyEditing()) {
|
|
const editor = this.getInlineEditor();
|
|
const dateString = this.formatDateForInput(date);
|
|
const fieldType =
|
|
type === "due"
|
|
? "dueDate"
|
|
: type === "scheduled"
|
|
? "scheduledDate"
|
|
: type === "start"
|
|
? "startDate"
|
|
: type === "cancelled"
|
|
? "cancelledDate"
|
|
: type === "completed"
|
|
? "completedDate"
|
|
: null;
|
|
|
|
if (fieldType) {
|
|
editor.showMetadataEditor(
|
|
dateEl,
|
|
fieldType,
|
|
dateString,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
private renderProjectMetadata(metadataEl: HTMLElement) {
|
|
// Determine which project to display: original project or tgProject
|
|
let projectName: string | undefined;
|
|
let isReadonly = false;
|
|
|
|
if (this.task.metadata.project) {
|
|
// Use original project if available
|
|
projectName = this.task.metadata.project;
|
|
} else if (this.task.metadata.tgProject) {
|
|
// Use tgProject as fallback
|
|
projectName = this.task.metadata.tgProject.name;
|
|
isReadonly = this.task.metadata.tgProject.readonly || false;
|
|
}
|
|
|
|
if (!projectName) return;
|
|
|
|
const projectEl = metadataEl.createEl("div", {
|
|
cls: "task-project",
|
|
});
|
|
|
|
// Add a visual indicator for tgProject
|
|
if (!this.task.metadata.project && this.task.metadata.tgProject) {
|
|
projectEl.addClass("task-project-tg");
|
|
projectEl.title = `Project from ${
|
|
this.task.metadata.tgProject.type
|
|
}: ${this.task.metadata.tgProject.source || ""}`;
|
|
}
|
|
|
|
projectEl.textContent = projectName.split("/").pop() || projectName;
|
|
|
|
// Make project clickable for editing only if inline editor is enabled and not readonly
|
|
if (this.plugin.settings.enableInlineEditor && !isReadonly) {
|
|
this.registerDomEvent(projectEl, "click", (e) => {
|
|
e.stopPropagation();
|
|
if (!this.isCurrentlyEditing()) {
|
|
const editor = this.getInlineEditor();
|
|
editor.showMetadataEditor(
|
|
projectEl,
|
|
"project",
|
|
this.task.metadata.project || "",
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
private renderTagsMetadata(metadataEl: HTMLElement) {
|
|
const tagsContainer = metadataEl.createEl("div", {
|
|
cls: "task-tags-container",
|
|
});
|
|
|
|
const projectPrefix =
|
|
this.plugin.settings.projectTagPrefix[
|
|
this.plugin.settings.preferMetadataFormat
|
|
] || "project";
|
|
this.task.metadata.tags
|
|
.filter((tag) => !tag.startsWith(`#${projectPrefix}`))
|
|
.forEach((tag) => {
|
|
const tagEl = tagsContainer.createEl("span", {
|
|
cls: "task-tag",
|
|
text: tag.startsWith("#") ? tag : `#${tag}`,
|
|
});
|
|
|
|
// Make tag clickable for editing only if inline editor is enabled
|
|
if (this.plugin.settings.enableInlineEditor) {
|
|
this.registerDomEvent(tagEl, "click", (e) => {
|
|
e.stopPropagation();
|
|
if (!this.isCurrentlyEditing()) {
|
|
const editor = this.getInlineEditor();
|
|
const tagsString =
|
|
this.task.metadata.tags?.join(", ") || "";
|
|
editor.showMetadataEditor(
|
|
tagsContainer,
|
|
"tags",
|
|
tagsString,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
private renderRecurrenceMetadata(metadataEl: HTMLElement) {
|
|
const recurrenceEl = metadataEl.createEl("div", {
|
|
cls: "task-date task-recurrence",
|
|
});
|
|
recurrenceEl.textContent = this.task.metadata.recurrence || "";
|
|
|
|
// Make recurrence clickable for editing only if inline editor is enabled
|
|
if (this.plugin.settings.enableInlineEditor) {
|
|
this.registerDomEvent(recurrenceEl, "click", (e) => {
|
|
e.stopPropagation();
|
|
if (!this.isCurrentlyEditing()) {
|
|
const editor = this.getInlineEditor();
|
|
editor.showMetadataEditor(
|
|
recurrenceEl,
|
|
"recurrence",
|
|
this.task.metadata.recurrence || "",
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
private renderOnCompletionMetadata(metadataEl: HTMLElement) {
|
|
const onCompletionEl = metadataEl.createEl("div", {
|
|
cls: "task-oncompletion",
|
|
});
|
|
onCompletionEl.textContent = `🏁 ${this.task.metadata.onCompletion}`;
|
|
|
|
// Make onCompletion clickable for editing only if inline editor is enabled
|
|
if (this.plugin.settings.enableInlineEditor) {
|
|
this.registerDomEvent(onCompletionEl, "click", (e) => {
|
|
e.stopPropagation();
|
|
if (!this.isCurrentlyEditing()) {
|
|
const editor = this.getInlineEditor();
|
|
editor.showMetadataEditor(
|
|
onCompletionEl,
|
|
"onCompletion",
|
|
this.task.metadata.onCompletion || "",
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
private renderDependsOnMetadata(metadataEl: HTMLElement) {
|
|
const dependsOnEl = metadataEl.createEl("div", {
|
|
cls: "task-dependson",
|
|
});
|
|
dependsOnEl.textContent = `⛔ ${this.task.metadata.dependsOn?.join(
|
|
", ",
|
|
)}`;
|
|
|
|
// Make dependsOn clickable for editing only if inline editor is enabled
|
|
if (this.plugin.settings.enableInlineEditor) {
|
|
this.registerDomEvent(dependsOnEl, "click", (e) => {
|
|
e.stopPropagation();
|
|
if (!this.isCurrentlyEditing()) {
|
|
const editor = this.getInlineEditor();
|
|
editor.showMetadataEditor(
|
|
dependsOnEl,
|
|
"dependsOn",
|
|
this.task.metadata.dependsOn?.join(", ") || "",
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
private renderIdMetadata(metadataEl: HTMLElement) {
|
|
const idEl = metadataEl.createEl("div", {
|
|
cls: "task-id",
|
|
});
|
|
idEl.textContent = `🆔 ${this.task.metadata.id}`;
|
|
|
|
// Make id clickable for editing only if inline editor is enabled
|
|
if (this.plugin.settings.enableInlineEditor) {
|
|
this.registerDomEvent(idEl, "click", (e) => {
|
|
e.stopPropagation();
|
|
if (!this.isCurrentlyEditing()) {
|
|
const editor = this.getInlineEditor();
|
|
editor.showMetadataEditor(
|
|
idEl,
|
|
"id",
|
|
this.task.metadata.id || "",
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
private renderAddMetadataButton(metadataEl: HTMLElement) {
|
|
// Only show add metadata button if inline editor is enabled
|
|
if (!this.plugin.settings.enableInlineEditor) {
|
|
return;
|
|
}
|
|
|
|
const addButtonContainer = metadataEl.createDiv({
|
|
cls: "add-metadata-container",
|
|
});
|
|
|
|
// Create the add metadata button
|
|
const addBtn = addButtonContainer.createEl("button", {
|
|
cls: "add-metadata-btn",
|
|
attr: { "aria-label": "Add metadata" },
|
|
});
|
|
setIcon(addBtn, "plus");
|
|
|
|
this.registerDomEvent(addBtn, "click", (e) => {
|
|
e.stopPropagation();
|
|
// Show metadata menu directly instead of calling showAddMetadataButton
|
|
this.showMetadataMenu(addBtn);
|
|
});
|
|
}
|
|
|
|
private showMetadataMenu(buttonEl: HTMLElement): void {
|
|
const editor = this.getInlineEditor();
|
|
|
|
// Create a temporary menu container
|
|
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" },
|
|
];
|
|
|
|
// Filter out fields that already have values
|
|
const fieldsToShow = availableFields.filter((field) => {
|
|
switch (field.key) {
|
|
case "project":
|
|
return !this.task.metadata.project;
|
|
case "tags":
|
|
return (
|
|
!this.task.metadata.tags ||
|
|
this.task.metadata.tags.length === 0
|
|
);
|
|
case "context":
|
|
return !this.task.metadata.context;
|
|
case "dueDate":
|
|
return !this.task.metadata.dueDate;
|
|
case "startDate":
|
|
return !this.task.metadata.startDate;
|
|
case "scheduledDate":
|
|
return !this.task.metadata.scheduledDate;
|
|
case "cancelledDate":
|
|
return !this.task.metadata.cancelledDate;
|
|
case "completedDate":
|
|
return !this.task.metadata.completedDate;
|
|
case "priority":
|
|
return !this.task.metadata.priority;
|
|
case "recurrence":
|
|
return !this.task.metadata.recurrence;
|
|
case "onCompletion":
|
|
return !this.task.metadata.onCompletion;
|
|
case "dependsOn":
|
|
return (
|
|
!this.task.metadata.dependsOn ||
|
|
this.task.metadata.dependsOn.length === 0
|
|
);
|
|
case "id":
|
|
return !this.task.metadata.id;
|
|
default:
|
|
return true;
|
|
}
|
|
});
|
|
|
|
// If no fields are available to add, show a message
|
|
if (fieldsToShow.length === 0) {
|
|
menu.addItem((item) => {
|
|
item.setTitle(
|
|
"All metadata fields are already set",
|
|
).setDisabled(true);
|
|
});
|
|
} else {
|
|
fieldsToShow.forEach((field) => {
|
|
menu.addItem((item: any) => {
|
|
item.setTitle(field.label)
|
|
.setIcon(field.icon)
|
|
.onClick(() => {
|
|
// Create a temporary container for the metadata editor
|
|
const tempContainer =
|
|
buttonEl.parentElement!.createDiv({
|
|
cls: "temp-metadata-editor-container",
|
|
});
|
|
|
|
editor.showMetadataEditor(
|
|
tempContainer,
|
|
field.key as any,
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
menu.showAtPosition({
|
|
x: buttonEl.getBoundingClientRect().left,
|
|
y: buttonEl.getBoundingClientRect().bottom,
|
|
});
|
|
}
|
|
|
|
private formatDateForInput(date: Date): string {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
const day = String(date.getDate()).padStart(2, "0");
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
private renderMarkdown() {
|
|
// Clear existing content if needed
|
|
if (this.markdownRenderer) {
|
|
this.removeChild(this.markdownRenderer);
|
|
}
|
|
|
|
// Clear the content element
|
|
this.contentEl.empty();
|
|
|
|
// Create new renderer
|
|
this.markdownRenderer = new MarkdownRendererComponent(
|
|
this.app,
|
|
this.contentEl,
|
|
this.task.filePath,
|
|
);
|
|
this.addChild(this.markdownRenderer);
|
|
|
|
// Render the markdown content
|
|
this.markdownRenderer.render(this.task.originalMarkdown);
|
|
|
|
// Re-register the click event for editing after rendering
|
|
this.registerContentClickHandler();
|
|
|
|
// Update layout mode after content is rendered
|
|
// Use requestAnimationFrame to ensure the content is fully rendered
|
|
requestAnimationFrame(() => {
|
|
this.updateLayoutMode();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Detect content height and update layout mode
|
|
*/
|
|
private updateLayoutMode() {
|
|
if (!this.contentEl || !this.contentMetadataContainer) {
|
|
return;
|
|
}
|
|
|
|
// Check if dynamic metadata positioning is enabled
|
|
if (!this.plugin.settings.enableDynamicMetadataPositioning) {
|
|
// If disabled, always use multi-line (traditional) layout
|
|
this.contentMetadataContainer.toggleClass(
|
|
"multi-line-content",
|
|
true,
|
|
);
|
|
this.contentMetadataContainer.toggleClass(
|
|
"single-line-content",
|
|
false,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Get the line height of the content element
|
|
const computedStyle = window.getComputedStyle(this.contentEl);
|
|
const lineHeight =
|
|
parseFloat(computedStyle.lineHeight) ||
|
|
parseFloat(computedStyle.fontSize) * 1.4;
|
|
|
|
// Get actual content height
|
|
const contentHeight = this.contentEl.scrollHeight;
|
|
|
|
// Check if content is multi-line (with some tolerance)
|
|
const isMultiLine = contentHeight > lineHeight * 1.2;
|
|
|
|
// Apply appropriate layout class using Obsidian's toggleClass method
|
|
this.contentMetadataContainer.toggleClass(
|
|
"multi-line-content",
|
|
isMultiLine,
|
|
);
|
|
this.contentMetadataContainer.toggleClass(
|
|
"single-line-content",
|
|
!isMultiLine,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Register click handler for content editing
|
|
*/
|
|
private registerContentClickHandler() {
|
|
// Make content clickable for editing or navigation
|
|
this.registerDomEvent(this.contentEl, "click", async (e) => {
|
|
// Check if modifier key is pressed (Cmd/Ctrl)
|
|
if (Keymap.isModEvent(e)) {
|
|
// Open task in file
|
|
e.stopPropagation();
|
|
await this.openTaskInFile();
|
|
} 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
|
|
const editor = this.getInlineEditor();
|
|
editor.showContentEditor(this.contentEl);
|
|
}
|
|
// If inline editor is disabled, let the click bubble up to select the task
|
|
});
|
|
}
|
|
|
|
private updateTaskDisplay() {
|
|
// Re-render the task content
|
|
this.renderTaskContent();
|
|
}
|
|
|
|
private renderChildTasks() {
|
|
// Clear existing child components
|
|
this.childComponents.forEach((component) => {
|
|
component.unload();
|
|
});
|
|
this.childComponents = [];
|
|
|
|
// Clear child container
|
|
this.childrenContainer.empty();
|
|
|
|
// Set visibility based on expanded state
|
|
this.isExpanded
|
|
? this.childrenContainer.show()
|
|
: this.childrenContainer.hide();
|
|
|
|
// Get view configuration to check if we should hide completed and abandoned tasks
|
|
const viewConfig = getViewSettingOrDefault(
|
|
this.plugin,
|
|
this.viewMode as ViewMode,
|
|
);
|
|
const abandonedStatus =
|
|
this.plugin.settings.taskStatuses.abandoned.split("|");
|
|
const completedStatus =
|
|
this.plugin.settings.taskStatuses.completed.split("|");
|
|
|
|
// Filter child tasks based on view configuration
|
|
let tasksToRender = this.childTasks;
|
|
if (viewConfig.hideCompletedAndAbandonedTasks) {
|
|
tasksToRender = this.childTasks.filter((task) => {
|
|
return (
|
|
!task.completed &&
|
|
!abandonedStatus.includes(task.status.toLowerCase()) &&
|
|
!completedStatus.includes(task.status.toLowerCase())
|
|
);
|
|
});
|
|
}
|
|
// Sort children using the same criteria as list view (fallback to sensible defaults)
|
|
const childSortCriteria = viewConfig.sortCriteria;
|
|
if (childSortCriteria && childSortCriteria.length > 0) {
|
|
tasksToRender = sortTasks(
|
|
[...tasksToRender],
|
|
childSortCriteria,
|
|
this.plugin.settings,
|
|
);
|
|
} else {
|
|
// Default sorting: incomplete first, then priority (high->low), due date (earlier->later), content; tie-break by filePath->line
|
|
tasksToRender = [...tasksToRender].sort((a, b) => {
|
|
const completedA = a.completed;
|
|
const completedB = b.completed;
|
|
if (completedA !== completedB) return completedA ? 1 : -1;
|
|
|
|
const prioA = a.metadata.priority ?? 0;
|
|
const prioB = b.metadata.priority ?? 0;
|
|
if (prioA !== prioB) return prioB - prioA;
|
|
|
|
const dueA = a.metadata.dueDate ?? Infinity;
|
|
const dueB = b.metadata.dueDate ?? Infinity;
|
|
if (dueA !== dueB) return dueA - dueB;
|
|
|
|
const collator = new Intl.Collator(undefined, {
|
|
usage: "sort",
|
|
sensitivity: "base",
|
|
numeric: true,
|
|
});
|
|
const contentCmp = collator.compare(
|
|
a.content ?? "",
|
|
b.content ?? "",
|
|
);
|
|
if (contentCmp !== 0) return contentCmp;
|
|
const fp = (a.filePath || "").localeCompare(b.filePath || "");
|
|
if (fp !== 0) return fp;
|
|
return (a.line ?? 0) - (b.line ?? 0);
|
|
});
|
|
}
|
|
|
|
// Render each filtered child task
|
|
tasksToRender.forEach((childTask) => {
|
|
// Find *grandchildren* by looking up children of the current childTask in the *full* taskMap
|
|
const grandchildren: Task[] = [];
|
|
this.taskMap.forEach((potentialGrandchild) => {
|
|
if (potentialGrandchild.metadata.parent === childTask.id) {
|
|
grandchildren.push(potentialGrandchild);
|
|
}
|
|
});
|
|
|
|
const childComponent = new TaskTreeItemComponent(
|
|
childTask,
|
|
this.viewMode,
|
|
this.app,
|
|
this.indentLevel + 1,
|
|
grandchildren, // Pass the correctly found grandchildren
|
|
this.taskMap, // Pass the map down recursively
|
|
this.plugin, // Pass the plugin down
|
|
this.selectionManager || undefined, // Pass selection manager down
|
|
);
|
|
|
|
// Pass up events
|
|
childComponent.onTaskSelected = (task) => {
|
|
if (this.onTaskSelected) {
|
|
this.onTaskSelected(task);
|
|
}
|
|
};
|
|
|
|
childComponent.onTaskCompleted = (task) => {
|
|
if (this.onTaskCompleted) {
|
|
this.onTaskCompleted(task);
|
|
}
|
|
};
|
|
|
|
childComponent.onToggleExpand = (taskId, isExpanded) => {
|
|
if (this.onToggleExpand) {
|
|
this.onToggleExpand(taskId, isExpanded);
|
|
}
|
|
};
|
|
|
|
childComponent.onTaskContextMenu = (event, task) => {
|
|
if (this.onTaskContextMenu) {
|
|
this.onTaskContextMenu(event, task);
|
|
}
|
|
};
|
|
|
|
// Pass up onTaskUpdate - CRITICAL: This was missing and causing the callback to not be available
|
|
childComponent.onTaskUpdate = async (originalTask, updatedTask) => {
|
|
if (this.onTaskUpdate) {
|
|
await this.onTaskUpdate(originalTask, updatedTask);
|
|
}
|
|
};
|
|
|
|
// Load component
|
|
this.addChild(childComponent);
|
|
childComponent.load();
|
|
|
|
// Add to DOM
|
|
this.childrenContainer.appendChild(childComponent.element);
|
|
|
|
// Store for later cleanup
|
|
this.childComponents.push(childComponent);
|
|
});
|
|
}
|
|
|
|
public updateChildTasks(childTasks: Task[]) {
|
|
this.childTasks = childTasks;
|
|
this.renderChildTasks();
|
|
}
|
|
|
|
private selectTask() {
|
|
if (this.onTaskSelected) {
|
|
this.onTaskSelected(this.task);
|
|
}
|
|
}
|
|
|
|
private toggleTaskCompletion() {
|
|
// 创建任务的副本并切换完成状态
|
|
const updatedTask: Task = {
|
|
...this.task,
|
|
completed: !this.task.completed,
|
|
};
|
|
|
|
// 如果任务被标记为完成,设置完成日期
|
|
if (!this.task.completed) {
|
|
updatedTask.metadata = {
|
|
...this.task.metadata,
|
|
completedDate: Date.now(),
|
|
};
|
|
} else {
|
|
// 如果任务被标记为未完成,移除完成日期
|
|
updatedTask.metadata = {
|
|
...this.task.metadata,
|
|
completedDate: undefined,
|
|
};
|
|
}
|
|
|
|
if (this.onTaskCompleted) {
|
|
this.onTaskCompleted(updatedTask);
|
|
}
|
|
}
|
|
|
|
private toggleExpand() {
|
|
this.isExpanded = !this.isExpanded;
|
|
|
|
if (this.toggleEl instanceof HTMLElement) {
|
|
setIcon(
|
|
this.toggleEl,
|
|
this.isExpanded ? "chevron-down" : "chevron-right",
|
|
);
|
|
}
|
|
|
|
// Show/hide children
|
|
this.isExpanded
|
|
? this.childrenContainer.show()
|
|
: this.childrenContainer.hide();
|
|
|
|
// Notify parent
|
|
if (this.onToggleExpand) {
|
|
this.onToggleExpand(this.task.id, this.isExpanded);
|
|
}
|
|
}
|
|
|
|
private async openTaskInFile() {
|
|
const file = this.app.vault.getFileByPath(this.task.filePath);
|
|
if (file) {
|
|
const leaf = this.app.workspace.getLeaf(false);
|
|
await leaf.openFile(file, {
|
|
eState: {
|
|
line: this.task.line,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
public setSelected(selected: boolean) {
|
|
this.isSelected = selected;
|
|
this.element.classList.toggle("selected", selected);
|
|
}
|
|
|
|
public updateTask(task: Task) {
|
|
const oldTask = this.task;
|
|
this.task = task;
|
|
this.renderTaskContent();
|
|
|
|
// Update completion status
|
|
if (oldTask.completed !== task.completed) {
|
|
if (task.completed) {
|
|
this.element.classList.add("task-completed");
|
|
} else {
|
|
this.element.classList.remove("task-completed");
|
|
}
|
|
}
|
|
|
|
// If content or originalMarkdown changed, update the markdown display
|
|
if (
|
|
oldTask.originalMarkdown !== task.originalMarkdown ||
|
|
oldTask.content !== task.content
|
|
) {
|
|
// Re-render the markdown content
|
|
this.contentEl.empty();
|
|
this.renderMarkdown();
|
|
}
|
|
|
|
// Check if metadata changed and need full refresh
|
|
if (
|
|
JSON.stringify(oldTask.metadata) !== JSON.stringify(task.metadata)
|
|
) {
|
|
// Re-render metadata
|
|
const metadataEl = this.parentContainer.querySelector(
|
|
".task-metadata",
|
|
) as HTMLElement;
|
|
if (metadataEl) {
|
|
this.renderMetadata(metadataEl);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Attempts to find and update a task within this component's children.
|
|
* @param updatedTask The task data to update.
|
|
* @returns True if the task was found and updated in the subtree, false otherwise.
|
|
*/
|
|
public updateTaskRecursively(updatedTask: Task): boolean {
|
|
// Iterate through the direct child components of this item
|
|
for (const childComp of this.childComponents) {
|
|
// Check if the direct child is the task we're looking for
|
|
if (childComp.getTask().id === updatedTask.id) {
|
|
childComp.updateTask(updatedTask); // Update the child directly
|
|
return true; // Task found and updated
|
|
} else {
|
|
// If not a direct child, ask this child to check its own children recursively
|
|
const foundInChildren =
|
|
childComp.updateTaskRecursively(updatedTask);
|
|
if (foundInChildren) {
|
|
return true; // Task was found deeper in this child's subtree
|
|
}
|
|
}
|
|
}
|
|
// If the loop finishes, the task was not found in this component's subtree
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Find a component in this subtree by task id.
|
|
*/
|
|
public findComponentByTaskId(taskId: string): TaskTreeItemComponent | null {
|
|
if (this.task.id === taskId) return this;
|
|
for (const child of this.childComponents) {
|
|
const found = child.findComponentByTaskId(taskId);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Remove a child component (any depth) by task id. Returns true if removed.
|
|
*/
|
|
public removeChildByTaskId(taskId: string): boolean {
|
|
for (let i = 0; i < this.childComponents.length; i++) {
|
|
const child = this.childComponents[i];
|
|
if (child.getTask().id === taskId) {
|
|
child.unload();
|
|
this.childComponents.splice(i, 1);
|
|
return true;
|
|
}
|
|
if (child.removeChildByTaskId(taskId)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
public getTask(): Task {
|
|
return this.task;
|
|
}
|
|
|
|
/**
|
|
* Updates the visual selection state of this component and its children.
|
|
* @param selectedId The ID of the task that should be marked as selected, or null to deselect all.
|
|
*/
|
|
public updateSelectionVisuals(selectedId: string | null) {
|
|
const isNowSelected = this.task.id === selectedId;
|
|
if (this.isSelected !== isNowSelected) {
|
|
this.isSelected = isNowSelected;
|
|
// Use the existing element reference if available, otherwise querySelector
|
|
const elementToToggle =
|
|
this.element ||
|
|
this.parentContainer?.closest(".tree-task-item");
|
|
if (elementToToggle) {
|
|
elementToToggle.classList.toggle(
|
|
"is-selected",
|
|
this.isSelected,
|
|
);
|
|
// Also ensure the parent container reflects selection if separate element
|
|
if (this.parentContainer) {
|
|
this.parentContainer.classList.toggle(
|
|
"selected",
|
|
this.isSelected,
|
|
);
|
|
}
|
|
} else {
|
|
console.warn(
|
|
"Could not find element to toggle selection class for task:",
|
|
this.task.id,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Recursively update children
|
|
this.childComponents.forEach((child) =>
|
|
child.updateSelectionVisuals(selectedId),
|
|
);
|
|
}
|
|
|
|
public setExpanded(expanded: boolean) {
|
|
if (this.isExpanded !== expanded) {
|
|
this.isExpanded = expanded;
|
|
|
|
// Update icon
|
|
if (this.toggleEl instanceof HTMLElement) {
|
|
setIcon(
|
|
this.toggleEl,
|
|
this.isExpanded ? "chevron-down" : "chevron-right",
|
|
);
|
|
}
|
|
|
|
// Show/hide children
|
|
this.isExpanded
|
|
? this.childrenContainer.show()
|
|
: this.childrenContainer.hide();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
TaskTreeItemComponent.editorManager?.hasActiveEditor(this.task.id)
|
|
) {
|
|
TaskTreeItemComponent.editorManager.releaseEditor(this.task.id);
|
|
}
|
|
|
|
// Clean up child components
|
|
this.childComponents.forEach((component) => {
|
|
component.unload();
|
|
});
|
|
|
|
// Remove element from DOM if it exists
|
|
if (this.element && this.element.parentNode) {
|
|
this.element.remove();
|
|
}
|
|
}
|
|
}
|