mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
refactor(v2): decompose TaskViewV2 into stateless manager classes
Split monolithic TaskViewV2 (3207 lines) into 6 focused managers: - FluentDataManager: data loading and filtering - FluentLayoutManager: layout, sidebar, details panel, action buttons - FluentComponentManager: view component orchestration - FluentActionHandlers: user action handling - FluentGestureManager: mobile gesture support - FluentWorkspaceStateManager: state persistence Key fixes: - Restore missing DOM layers (tg-v2-main-container, tg-v2-content-wrapper) - Add action buttons (Details, Capture, Filter, Reset Filter) to header - Fix project selection to update filter state and trigger events - Add filter-changed event listener for cross-view filtering - Prevent double rendering by centralizing updateView calls Architecture: - TaskViewV2 is now single source of truth for all state - Managers are stateless executors that communicate via callbacks - State flows: Manager executes → Callback → TaskViewV2 updates state
This commit is contained in:
parent
148f1633a8
commit
68247f03be
9 changed files with 3330 additions and 2995 deletions
BIN
src/components/features/onboarding/FluentPlacement.ts
Normal file
BIN
src/components/features/onboarding/FluentPlacement.ts
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load diff
545
src/experimental/v2/managers/FluentActionHandlers.ts
Normal file
545
src/experimental/v2/managers/FluentActionHandlers.ts
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
import { Component, Menu, Notice, TFile } from "obsidian";
|
||||
import { App } from "obsidian";
|
||||
import TaskProgressBarPlugin from "@/index";
|
||||
import { Task } from "@/types/task";
|
||||
import { QuickCaptureModal } from "@/components/features/quick-capture/modals/QuickCaptureModal";
|
||||
import { ConfirmModal } from "@/components/ui/modals/ConfirmModal";
|
||||
import { createTaskCheckbox } from "@/components/features/task/view/details";
|
||||
import { emitTaskSelected } from "../events/ui-event";
|
||||
import { t } from "@/translations/helper";
|
||||
import { ViewMode } from "../components/V2TopNavigation";
|
||||
|
||||
/**
|
||||
* FluentActionHandlers - Handles all user actions and task operations
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Task selection and deselection
|
||||
* - Task completion toggling
|
||||
* - Task updates (status, metadata, etc.)
|
||||
* - Task context menus
|
||||
* - Task deletion (with children support)
|
||||
* - Navigation actions (view switch, project select, search)
|
||||
* - Settings and UI actions
|
||||
*/
|
||||
export class FluentActionHandlers extends Component {
|
||||
// Task selection state
|
||||
private currentSelectedTaskId: string | null = null;
|
||||
private lastToggleTimestamp = 0;
|
||||
|
||||
// Callbacks
|
||||
private onTaskSelectionChanged?: (task: Task | null) => void;
|
||||
private onTaskUpdated?: (taskId: string, updatedTask: Task) => void;
|
||||
private onTaskDeleted?: (taskId: string, deleteChildren: boolean) => void;
|
||||
private onNavigateToView?: (viewId: string) => void;
|
||||
private onSearchQueryChanged?: (query: string) => void;
|
||||
private onProjectSelected?: (projectId: string) => void;
|
||||
private onViewModeChanged?: (mode: ViewMode) => void;
|
||||
private showDetailsPanel?: (task: Task) => void;
|
||||
private toggleDetailsVisibility?: (visible: boolean) => void;
|
||||
private getIsDetailsVisible?: () => boolean;
|
||||
|
||||
constructor(
|
||||
private app: App,
|
||||
private plugin: TaskProgressBarPlugin,
|
||||
private getWorkspaceId: () => string,
|
||||
private useSideLeaves: () => boolean
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callbacks for action results
|
||||
*/
|
||||
setCallbacks(callbacks: {
|
||||
onTaskSelectionChanged?: (task: Task | null) => void;
|
||||
onTaskUpdated?: (taskId: string, updatedTask: Task) => void;
|
||||
onTaskDeleted?: (taskId: string, deleteChildren: boolean) => void;
|
||||
onNavigateToView?: (viewId: string) => void;
|
||||
onSearchQueryChanged?: (query: string) => void;
|
||||
onProjectSelected?: (projectId: string) => void;
|
||||
onViewModeChanged?: (mode: ViewMode) => void;
|
||||
showDetailsPanel?: (task: Task) => void;
|
||||
toggleDetailsVisibility?: (visible: boolean) => void;
|
||||
getIsDetailsVisible?: () => boolean;
|
||||
}): void {
|
||||
this.onTaskSelectionChanged = callbacks.onTaskSelectionChanged;
|
||||
this.onTaskUpdated = callbacks.onTaskUpdated;
|
||||
this.onTaskDeleted = callbacks.onTaskDeleted;
|
||||
this.onNavigateToView = callbacks.onNavigateToView;
|
||||
this.onSearchQueryChanged = callbacks.onSearchQueryChanged;
|
||||
this.onProjectSelected = callbacks.onProjectSelected;
|
||||
this.onViewModeChanged = callbacks.onViewModeChanged;
|
||||
this.showDetailsPanel = callbacks.showDetailsPanel;
|
||||
this.toggleDetailsVisibility = callbacks.toggleDetailsVisibility;
|
||||
this.getIsDetailsVisible = callbacks.getIsDetailsVisible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle task selection
|
||||
*/
|
||||
handleTaskSelection(task: Task | null): void {
|
||||
// Emit cross-view selection when using side leaves
|
||||
if (this.useSideLeaves()) {
|
||||
emitTaskSelected(this.app, {
|
||||
taskId: task?.id ?? null,
|
||||
origin: "main",
|
||||
workspaceId: this.getWorkspaceId(),
|
||||
});
|
||||
}
|
||||
|
||||
if (task) {
|
||||
const now = Date.now();
|
||||
const timeSinceLastToggle = now - this.lastToggleTimestamp;
|
||||
|
||||
if (this.currentSelectedTaskId !== task.id) {
|
||||
this.currentSelectedTaskId = task.id;
|
||||
this.showDetailsPanel?.(task);
|
||||
const isDetailsVisible = this.getIsDetailsVisible?.() ?? false;
|
||||
if (!isDetailsVisible) {
|
||||
this.toggleDetailsVisibility?.(true);
|
||||
}
|
||||
this.lastToggleTimestamp = now;
|
||||
this.onTaskSelectionChanged?.(task);
|
||||
return;
|
||||
}
|
||||
|
||||
if (timeSinceLastToggle > 150) {
|
||||
const isDetailsVisible = this.getIsDetailsVisible?.() ?? false;
|
||||
this.toggleDetailsVisibility?.(!isDetailsVisible);
|
||||
this.lastToggleTimestamp = now;
|
||||
}
|
||||
} else {
|
||||
this.toggleDetailsVisibility?.(false);
|
||||
this.currentSelectedTaskId = null;
|
||||
this.onTaskSelectionChanged?.(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle task completion status
|
||||
*/
|
||||
async toggleTaskCompletion(task: Task): Promise<void> {
|
||||
if (!this.plugin.writeAPI) {
|
||||
new Notice("WriteAPI not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedTask = { ...task, completed: !task.completed };
|
||||
|
||||
if (updatedTask.completed) {
|
||||
updatedTask.metadata.completedDate = Date.now();
|
||||
const completedMark = (
|
||||
this.plugin.settings.taskStatuses.completed || "x"
|
||||
).split("|")[0];
|
||||
if (updatedTask.status !== completedMark) {
|
||||
updatedTask.status = completedMark;
|
||||
}
|
||||
} else {
|
||||
updatedTask.metadata.completedDate = undefined;
|
||||
const notStartedMark =
|
||||
this.plugin.settings.taskStatuses.notStarted || " ";
|
||||
if (this.isCompletedMark(updatedTask.status)) {
|
||||
updatedTask.status = notStartedMark;
|
||||
}
|
||||
}
|
||||
|
||||
await this.handleTaskUpdate(task, updatedTask);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle task update
|
||||
*/
|
||||
async handleTaskUpdate(
|
||||
originalTask: Task,
|
||||
updatedTask: Task
|
||||
): Promise<void> {
|
||||
if (!this.plugin.writeAPI) {
|
||||
console.error("WriteAPI not available");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updates = this.extractChangedFields(
|
||||
originalTask,
|
||||
updatedTask
|
||||
);
|
||||
const writeResult = await this.plugin.writeAPI.updateTask({
|
||||
taskId: originalTask.id,
|
||||
updates: updates,
|
||||
});
|
||||
|
||||
if (!writeResult.success) {
|
||||
throw new Error(writeResult.error || "Failed to update task");
|
||||
}
|
||||
|
||||
const updated = writeResult.task || updatedTask;
|
||||
|
||||
// Notify about task update
|
||||
this.onTaskUpdated?.(originalTask.id, updated);
|
||||
|
||||
new Notice(t("Task updated"));
|
||||
} catch (error) {
|
||||
console.error("Failed to update task:", error);
|
||||
new Notice("Failed to update task");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle kanban task status update
|
||||
*/
|
||||
async handleKanbanTaskStatusUpdate(
|
||||
task: Task,
|
||||
newStatusMark: string
|
||||
): Promise<void> {
|
||||
const isCompleted = this.isCompletedMark(newStatusMark);
|
||||
const completedDate = isCompleted ? Date.now() : undefined;
|
||||
|
||||
if (task.status !== newStatusMark || task.completed !== isCompleted) {
|
||||
await this.handleTaskUpdate(task, {
|
||||
...task,
|
||||
status: newStatusMark,
|
||||
completed: isCompleted,
|
||||
metadata: {
|
||||
...task.metadata,
|
||||
completedDate: completedDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show task context menu
|
||||
*/
|
||||
handleTaskContextMenu(event: MouseEvent, task: Task): void {
|
||||
const menu = new Menu();
|
||||
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(t("Complete"));
|
||||
item.setIcon("check-square");
|
||||
item.onClick(() => {
|
||||
this.toggleTaskCompletion(task);
|
||||
});
|
||||
})
|
||||
.addItem((item) => {
|
||||
item.setIcon("square-pen");
|
||||
item.setTitle(t("Switch status"));
|
||||
const submenu = item.setSubmenu();
|
||||
|
||||
// Get unique statuses from taskStatusMarks
|
||||
const statusMarks = this.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((item) => {
|
||||
item.titleEl.createEl(
|
||||
"span",
|
||||
{
|
||||
cls: "status-option-checkbox",
|
||||
},
|
||||
(el) => {
|
||||
createTaskCheckbox(mark, task, el);
|
||||
}
|
||||
);
|
||||
item.titleEl.createEl("span", {
|
||||
cls: "status-option",
|
||||
text: status,
|
||||
});
|
||||
item.onClick(async () => {
|
||||
const willComplete = this.isCompletedMark(mark);
|
||||
const updatedTask = {
|
||||
...task,
|
||||
status: mark,
|
||||
completed: willComplete,
|
||||
};
|
||||
|
||||
if (!task.completed && willComplete) {
|
||||
updatedTask.metadata.completedDate = Date.now();
|
||||
} else if (task.completed && !willComplete) {
|
||||
updatedTask.metadata.completedDate = undefined;
|
||||
}
|
||||
|
||||
await this.handleTaskUpdate(task, updatedTask);
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.addSeparator()
|
||||
.addItem((item) => {
|
||||
item.setTitle(t("Edit"));
|
||||
item.setIcon("pencil");
|
||||
item.onClick(() => {
|
||||
this.handleTaskSelection(task);
|
||||
});
|
||||
})
|
||||
.addItem((item) => {
|
||||
item.setTitle(t("Edit in File"));
|
||||
item.setIcon("pencil");
|
||||
item.onClick(() => {
|
||||
this.editTask(task);
|
||||
});
|
||||
})
|
||||
.addSeparator()
|
||||
.addItem((item) => {
|
||||
item.setTitle(t("Delete Task"));
|
||||
item.setIcon("trash");
|
||||
item.onClick(() => {
|
||||
this.confirmAndDeleteTask(event, task);
|
||||
});
|
||||
});
|
||||
|
||||
menu.showAtMouseEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit task in file
|
||||
*/
|
||||
private async editTask(task: Task): Promise<void> {
|
||||
const file = this.app.vault.getFileByPath(task.filePath);
|
||||
if (!(file instanceof TFile)) return;
|
||||
const leaf = this.app.workspace.getLeaf(false);
|
||||
await leaf.openFile(file, {
|
||||
eState: { line: task.line },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm and delete task (with children option)
|
||||
*/
|
||||
private confirmAndDeleteTask(event: MouseEvent, task: Task): void {
|
||||
const hasChildren =
|
||||
task.metadata &&
|
||||
task.metadata.children &&
|
||||
task.metadata.children.length > 0;
|
||||
|
||||
if (hasChildren) {
|
||||
const menu = new Menu();
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(t("Delete task only"));
|
||||
item.setIcon("trash");
|
||||
item.onClick(() => {
|
||||
this.deleteTask(task, false);
|
||||
});
|
||||
});
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(t("Delete task and all subtasks"));
|
||||
item.setIcon("trash-2");
|
||||
item.onClick(() => {
|
||||
this.deleteTask(task, true);
|
||||
});
|
||||
});
|
||||
menu.addSeparator();
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(t("Cancel"));
|
||||
});
|
||||
menu.showAtMouseEvent(event);
|
||||
} else {
|
||||
const modal = new ConfirmModal(this.plugin, {
|
||||
title: t("Delete Task"),
|
||||
message: t("Are you sure you want to delete this task?"),
|
||||
confirmText: t("Delete"),
|
||||
cancelText: t("Cancel"),
|
||||
onConfirm: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.deleteTask(task, false);
|
||||
}
|
||||
},
|
||||
});
|
||||
modal.open();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete task (and optionally children)
|
||||
*/
|
||||
private async deleteTask(
|
||||
task: Task,
|
||||
deleteChildren: boolean
|
||||
): Promise<void> {
|
||||
if (!this.plugin.writeAPI) {
|
||||
console.error("WriteAPI not available for deleteTask");
|
||||
new Notice(t("Failed to delete task"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.plugin.writeAPI.deleteTask({
|
||||
taskId: task.id,
|
||||
deleteChildren,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
new Notice(t("Task deleted"));
|
||||
|
||||
// Notify about task deletion
|
||||
this.onTaskDeleted?.(task.id, deleteChildren);
|
||||
|
||||
// Clear selection if deleted task was selected
|
||||
if (this.currentSelectedTaskId === task.id) {
|
||||
this.handleTaskSelection(null);
|
||||
}
|
||||
} else {
|
||||
new Notice(
|
||||
t("Failed to delete task") +
|
||||
": " +
|
||||
(result.error || "Unknown error")
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting task:", error);
|
||||
new Notice(
|
||||
t("Failed to delete task") + ": " + (error as any).message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle navigation to a view or create new task
|
||||
*/
|
||||
handleNavigate(viewId: string): void {
|
||||
if (viewId === "new-task") {
|
||||
new QuickCaptureModal(this.app, this.plugin).open();
|
||||
} else {
|
||||
console.log(`[FluentAction] handleNavigate to ${viewId}`);
|
||||
this.onNavigateToView?.(viewId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle search query change
|
||||
*/
|
||||
handleSearch(query: string): void {
|
||||
this.onSearchQueryChanged?.(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle project selection
|
||||
*/
|
||||
handleProjectSelect(projectId: string): void {
|
||||
console.log(`[FluentAction] Project selected: ${projectId}`);
|
||||
this.onProjectSelected?.(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle view mode change (list/tree/kanban/calendar)
|
||||
*/
|
||||
handleViewModeChange(mode: ViewMode): void {
|
||||
this.onViewModeChanged?.(mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle settings button click
|
||||
*/
|
||||
handleSettingsClick(): void {
|
||||
// Open Obsidian settings and navigate to the plugin tab
|
||||
this.app.setting.open();
|
||||
this.app.setting.openTabById(this.plugin.manifest.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a status mark indicates completion
|
||||
*/
|
||||
private isCompletedMark(mark: string): boolean {
|
||||
if (!mark) return false;
|
||||
try {
|
||||
const lower = mark.toLowerCase();
|
||||
const completedCfg = String(
|
||||
this.plugin.settings.taskStatuses?.completed || "x"
|
||||
);
|
||||
const completedSet = completedCfg
|
||||
.split("|")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
return completedSet.includes(lower);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract changed fields from task update
|
||||
*/
|
||||
private extractChangedFields(
|
||||
originalTask: Task,
|
||||
updatedTask: Task
|
||||
): Partial<Task> {
|
||||
const changes: Partial<Task> = {};
|
||||
|
||||
// Check top-level fields
|
||||
if (originalTask.content !== updatedTask.content) {
|
||||
changes.content = updatedTask.content;
|
||||
}
|
||||
if (originalTask.completed !== updatedTask.completed) {
|
||||
changes.completed = updatedTask.completed;
|
||||
}
|
||||
if (originalTask.status !== updatedTask.status) {
|
||||
changes.status = updatedTask.status;
|
||||
}
|
||||
|
||||
// Check metadata fields
|
||||
const metadataChanges: Partial<typeof originalTask.metadata> = {};
|
||||
let hasMetadataChanges = false;
|
||||
|
||||
const metadataFields = [
|
||||
"priority",
|
||||
"project",
|
||||
"tags",
|
||||
"context",
|
||||
"dueDate",
|
||||
"startDate",
|
||||
"scheduledDate",
|
||||
"completedDate",
|
||||
"recurrence",
|
||||
];
|
||||
|
||||
for (const field of metadataFields) {
|
||||
const originalValue = (originalTask.metadata as any)?.[field];
|
||||
const updatedValue = (updatedTask.metadata as any)?.[field];
|
||||
|
||||
if (field === "tags") {
|
||||
const origTags = originalValue || [];
|
||||
const updTags = updatedValue || [];
|
||||
if (
|
||||
origTags.length !== updTags.length ||
|
||||
!origTags.every((t: string, i: number) => t === updTags[i])
|
||||
) {
|
||||
metadataChanges.tags = updTags;
|
||||
hasMetadataChanges = true;
|
||||
}
|
||||
} else if (originalValue !== updatedValue) {
|
||||
(metadataChanges as any)[field] = updatedValue;
|
||||
hasMetadataChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMetadataChanges) {
|
||||
changes.metadata = metadataChanges as any;
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current selected task ID
|
||||
*/
|
||||
getCurrentSelectedTaskId(): string | null {
|
||||
return this.currentSelectedTaskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear task selection
|
||||
*/
|
||||
clearSelection(): void {
|
||||
this.currentSelectedTaskId = null;
|
||||
this.onTaskSelectionChanged?.(null);
|
||||
}
|
||||
}
|
||||
880
src/experimental/v2/managers/FluentComponentManager.ts
Normal file
880
src/experimental/v2/managers/FluentComponentManager.ts
Normal file
|
|
@ -0,0 +1,880 @@
|
|||
import { Component, setIcon } from "obsidian";
|
||||
import { App } from "obsidian";
|
||||
import TaskProgressBarPlugin from "@/index";
|
||||
import { Task } from "@/types/task";
|
||||
import { ContentComponent } from "@/components/features/task/view/content";
|
||||
import { ForecastComponent } from "@/components/features/task/view/forecast";
|
||||
import { TagsComponent } from "@/components/features/task/view/tags";
|
||||
import { ProjectsComponent } from "@/components/features/task/view/projects";
|
||||
import { ReviewComponent } from "@/components/features/task/view/review";
|
||||
import { Habit } from "@/components/features/habit/habit";
|
||||
import { CalendarComponent } from "@/components/features/calendar";
|
||||
import { KanbanComponent } from "@/components/features/kanban/kanban";
|
||||
import { GanttComponent } from "@/components/features/gantt/gantt";
|
||||
import { ViewComponentManager } from "@/components/ui/behavior/ViewComponentManager";
|
||||
import { TaskPropertyTwoColumnView } from "@/components/features/task/view/TaskPropertyTwoColumnView";
|
||||
import {
|
||||
getViewSettingOrDefault,
|
||||
TwoColumnSpecificConfig,
|
||||
} from "@/common/setting-definition";
|
||||
import { filterTasks } from "@/utils/task/task-filter-utils";
|
||||
import { RootFilterState } from "@/components/features/task/filter/ViewTaskFilter";
|
||||
import { QuickCaptureModal } from "@/components/features/quick-capture/modals/QuickCaptureModal";
|
||||
import { t } from "@/translations/helper";
|
||||
import { ViewMode } from "../components/V2TopNavigation";
|
||||
|
||||
// View mode configuration for each view type
|
||||
const VIEW_MODE_CONFIG: Record<string, ViewMode[]> = {
|
||||
// Content-based views - support all modes
|
||||
inbox: ["list", "tree", "kanban", "calendar"],
|
||||
today: ["list", "tree", "kanban", "calendar"],
|
||||
upcoming: ["list", "tree", "kanban", "calendar"],
|
||||
flagged: ["list", "tree", "kanban", "calendar"],
|
||||
projects: ["list", "tree", "kanban", "calendar"],
|
||||
|
||||
// Specialized views with limited or no modes
|
||||
tags: [],
|
||||
review: [],
|
||||
forecast: [],
|
||||
habit: [],
|
||||
gantt: [],
|
||||
calendar: [],
|
||||
kanban: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* FluentComponentManager - Manages view component lifecycle
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Initialize all view components (Content, Forecast, Tags, Calendar, Kanban, etc.)
|
||||
* - Show/hide components based on active view
|
||||
* - Switch between views (inbox, today, projects, calendar, etc.)
|
||||
* - Render content with different view modes (list, tree, kanban, calendar)
|
||||
* - Render loading, error, and empty states
|
||||
*/
|
||||
export class FluentComponentManager extends Component {
|
||||
// View components
|
||||
private contentComponent: ContentComponent;
|
||||
private forecastComponent: ForecastComponent;
|
||||
private tagsComponent: TagsComponent;
|
||||
private projectsComponent: ProjectsComponent;
|
||||
private reviewComponent: ReviewComponent;
|
||||
private habitComponent: Habit;
|
||||
private calendarComponent: CalendarComponent;
|
||||
private kanbanComponent: KanbanComponent;
|
||||
private ganttComponent: GanttComponent;
|
||||
private viewComponentManager: ViewComponentManager;
|
||||
|
||||
// Two column view components
|
||||
private twoColumnViewComponents: Map<string, TaskPropertyTwoColumnView> =
|
||||
new Map();
|
||||
|
||||
// Legacy containers
|
||||
private listContainer: HTMLElement;
|
||||
private treeContainer: HTMLElement;
|
||||
|
||||
// Track currently visible component
|
||||
private currentVisibleComponent: any = null;
|
||||
|
||||
// View handlers
|
||||
private viewHandlers: {
|
||||
onTaskSelected: (task: Task) => void;
|
||||
onTaskCompleted: (task: Task) => void;
|
||||
onTaskUpdate: (originalTask: Task, updatedTask: Task) => Promise<void>;
|
||||
onTaskContextMenu: (event: MouseEvent, task: Task) => void;
|
||||
onKanbanTaskStatusUpdate: (taskId: string, newStatusMark: string) => void;
|
||||
};
|
||||
|
||||
constructor(
|
||||
private app: App,
|
||||
private plugin: TaskProgressBarPlugin,
|
||||
private contentArea: HTMLElement,
|
||||
private parentView: Component,
|
||||
viewHandlers: {
|
||||
onTaskSelected: (task: Task) => void;
|
||||
onTaskCompleted: (task: Task) => void;
|
||||
onTaskUpdate: (originalTask: Task, updatedTask: Task) => Promise<void>;
|
||||
onTaskContextMenu: (event: MouseEvent, task: Task) => void;
|
||||
onKanbanTaskStatusUpdate: (taskId: string, newStatusMark: string) => void;
|
||||
}
|
||||
) {
|
||||
super();
|
||||
this.viewHandlers = viewHandlers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all view components
|
||||
*/
|
||||
initializeViewComponents(): void {
|
||||
console.log("[FluentComponent] initializeViewComponents started");
|
||||
|
||||
// Initialize ViewComponentManager for special views
|
||||
const viewHandlers = {
|
||||
onTaskSelected: (task: Task) => this.viewHandlers.onTaskSelected(task),
|
||||
onTaskCompleted: (task: Task) => this.viewHandlers.onTaskCompleted(task),
|
||||
onTaskUpdate: async (originalTask: Task, updatedTask: Task) => {
|
||||
await this.viewHandlers.onTaskUpdate(originalTask, updatedTask);
|
||||
},
|
||||
onTaskContextMenu: (event: MouseEvent, task: Task) =>
|
||||
this.viewHandlers.onTaskContextMenu(event, task),
|
||||
};
|
||||
|
||||
this.viewComponentManager = new ViewComponentManager(
|
||||
this.parentView,
|
||||
this.app,
|
||||
this.plugin,
|
||||
this.contentArea,
|
||||
viewHandlers
|
||||
);
|
||||
this.parentView.addChild(this.viewComponentManager);
|
||||
|
||||
// Initialize ContentComponent (handles inbox, today, upcoming, flagged)
|
||||
console.log("[FluentComponent] Creating ContentComponent");
|
||||
this.contentComponent = new ContentComponent(
|
||||
this.contentArea,
|
||||
this.app,
|
||||
this.plugin,
|
||||
{
|
||||
onTaskSelected: (task) => {
|
||||
if (task) this.viewHandlers.onTaskSelected(task);
|
||||
},
|
||||
onTaskCompleted: (task) => {
|
||||
if (task) this.viewHandlers.onTaskCompleted(task);
|
||||
},
|
||||
onTaskContextMenu: (event, task) => {
|
||||
if (task) this.viewHandlers.onTaskContextMenu(event, task);
|
||||
},
|
||||
}
|
||||
);
|
||||
this.parentView.addChild(this.contentComponent);
|
||||
this.contentComponent.load();
|
||||
|
||||
// Initialize ForecastComponent
|
||||
this.forecastComponent = new ForecastComponent(
|
||||
this.contentArea,
|
||||
this.app,
|
||||
this.plugin,
|
||||
{
|
||||
onTaskSelected: (task) => {
|
||||
if (task) this.viewHandlers.onTaskSelected(task);
|
||||
},
|
||||
onTaskCompleted: (task) => {
|
||||
if (task) this.viewHandlers.onTaskCompleted(task);
|
||||
},
|
||||
onTaskUpdate: async (originalTask, updatedTask) => {
|
||||
if (originalTask && updatedTask) {
|
||||
await this.viewHandlers.onTaskUpdate(originalTask, updatedTask);
|
||||
}
|
||||
},
|
||||
onTaskContextMenu: (event, task) => {
|
||||
if (task) this.viewHandlers.onTaskContextMenu(event, task);
|
||||
},
|
||||
}
|
||||
);
|
||||
this.parentView.addChild(this.forecastComponent);
|
||||
this.forecastComponent.load();
|
||||
|
||||
// Initialize TagsComponent
|
||||
this.tagsComponent = new TagsComponent(
|
||||
this.contentArea,
|
||||
this.app,
|
||||
this.plugin,
|
||||
{
|
||||
onTaskSelected: (task) => {
|
||||
if (task) this.viewHandlers.onTaskSelected(task);
|
||||
},
|
||||
onTaskCompleted: (task) => {
|
||||
if (task) this.viewHandlers.onTaskCompleted(task);
|
||||
},
|
||||
onTaskUpdate: async (originalTask, updatedTask) => {
|
||||
if (originalTask && updatedTask) {
|
||||
await this.viewHandlers.onTaskUpdate(originalTask, updatedTask);
|
||||
}
|
||||
},
|
||||
onTaskContextMenu: (event, task) => {
|
||||
if (task) this.viewHandlers.onTaskContextMenu(event, task);
|
||||
},
|
||||
}
|
||||
);
|
||||
this.parentView.addChild(this.tagsComponent);
|
||||
this.tagsComponent.load();
|
||||
|
||||
// Initialize ProjectsComponent
|
||||
this.projectsComponent = new ProjectsComponent(
|
||||
this.contentArea,
|
||||
this.app,
|
||||
this.plugin,
|
||||
{
|
||||
onTaskSelected: (task) => {
|
||||
if (task) this.viewHandlers.onTaskSelected(task);
|
||||
},
|
||||
onTaskCompleted: (task) => {
|
||||
if (task) this.viewHandlers.onTaskCompleted(task);
|
||||
},
|
||||
onTaskUpdate: async (originalTask, updatedTask) => {
|
||||
if (originalTask && updatedTask) {
|
||||
await this.viewHandlers.onTaskUpdate(originalTask, updatedTask);
|
||||
}
|
||||
},
|
||||
onTaskContextMenu: (event, task) => {
|
||||
if (task) this.viewHandlers.onTaskContextMenu(event, task);
|
||||
},
|
||||
}
|
||||
);
|
||||
this.parentView.addChild(this.projectsComponent);
|
||||
this.projectsComponent.load();
|
||||
|
||||
// Initialize ReviewComponent
|
||||
this.reviewComponent = new ReviewComponent(
|
||||
this.contentArea,
|
||||
this.app,
|
||||
this.plugin,
|
||||
{
|
||||
onTaskSelected: (task) => {
|
||||
if (task) this.viewHandlers.onTaskSelected(task);
|
||||
},
|
||||
onTaskCompleted: (task) => {
|
||||
if (task) this.viewHandlers.onTaskCompleted(task);
|
||||
},
|
||||
onTaskUpdate: async (originalTask, updatedTask) => {
|
||||
if (originalTask && updatedTask) {
|
||||
await this.viewHandlers.onTaskUpdate(originalTask, updatedTask);
|
||||
}
|
||||
},
|
||||
onTaskContextMenu: (event, task) => {
|
||||
if (task) this.viewHandlers.onTaskContextMenu(event, task);
|
||||
},
|
||||
}
|
||||
);
|
||||
this.parentView.addChild(this.reviewComponent);
|
||||
this.reviewComponent.load();
|
||||
|
||||
// Initialize HabitComponent
|
||||
this.habitComponent = new Habit(this.plugin, this.contentArea);
|
||||
this.parentView.addChild(this.habitComponent);
|
||||
this.habitComponent.load();
|
||||
|
||||
// Initialize CalendarComponent
|
||||
this.calendarComponent = new CalendarComponent(
|
||||
this.app,
|
||||
this.plugin,
|
||||
this.contentArea,
|
||||
[], // tasks will be set later
|
||||
{
|
||||
onTaskSelected: (task: Task | null) => {
|
||||
if (task) this.viewHandlers.onTaskSelected(task);
|
||||
},
|
||||
onTaskCompleted: (task: Task) => {
|
||||
if (task) this.viewHandlers.onTaskCompleted(task);
|
||||
},
|
||||
onEventContextMenu: (ev: MouseEvent, event: any) => {
|
||||
if (event) this.viewHandlers.onTaskContextMenu(ev, event as any);
|
||||
},
|
||||
}
|
||||
);
|
||||
this.parentView.addChild(this.calendarComponent);
|
||||
|
||||
// Initialize KanbanComponent
|
||||
this.kanbanComponent = new KanbanComponent(
|
||||
this.app,
|
||||
this.plugin,
|
||||
this.contentArea,
|
||||
[], // tasks will be set later
|
||||
{
|
||||
onTaskStatusUpdate: async (taskId, newStatusMark) => {
|
||||
this.viewHandlers.onKanbanTaskStatusUpdate(taskId, newStatusMark);
|
||||
},
|
||||
onTaskSelected: (task) => {
|
||||
if (task) this.viewHandlers.onTaskSelected(task);
|
||||
},
|
||||
onTaskCompleted: (task) => {
|
||||
if (task) this.viewHandlers.onTaskCompleted(task);
|
||||
},
|
||||
onTaskContextMenu: (event, task) => {
|
||||
if (task) this.viewHandlers.onTaskContextMenu(event, task);
|
||||
},
|
||||
}
|
||||
);
|
||||
this.parentView.addChild(this.kanbanComponent);
|
||||
|
||||
// Initialize GanttComponent
|
||||
this.ganttComponent = new GanttComponent(
|
||||
this.plugin,
|
||||
this.contentArea,
|
||||
{
|
||||
onTaskSelected: (task: Task) =>
|
||||
this.viewHandlers.onTaskSelected(task),
|
||||
onTaskCompleted: (task: Task) =>
|
||||
this.viewHandlers.onTaskCompleted(task),
|
||||
onTaskContextMenu: (event: MouseEvent, task: Task) =>
|
||||
this.viewHandlers.onTaskContextMenu(event, task),
|
||||
}
|
||||
);
|
||||
this.parentView.addChild(this.ganttComponent);
|
||||
this.ganttComponent.load();
|
||||
|
||||
// Create legacy containers for backward compatibility
|
||||
this.listContainer = this.contentArea.createDiv({
|
||||
cls: "task-list-container",
|
||||
attr: { style: "display: none;" },
|
||||
});
|
||||
|
||||
this.treeContainer = this.contentArea.createDiv({
|
||||
cls: "task-tree-container",
|
||||
attr: { style: "display: none;" },
|
||||
});
|
||||
|
||||
// Hide all components initially
|
||||
console.log("[FluentComponent] Hiding all components initially");
|
||||
this.hideAllComponents(true);
|
||||
console.log("[FluentComponent] initializeViewComponents completed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide all components
|
||||
*/
|
||||
hideAllComponents(forceHideAll = false): void {
|
||||
const isInitialHide = forceHideAll;
|
||||
|
||||
// Smart hiding - only hide currently visible component (unless initial hide)
|
||||
if (!isInitialHide && this.currentVisibleComponent) {
|
||||
console.log(
|
||||
"[FluentComponent] Smart hide - only hiding current visible component"
|
||||
);
|
||||
this.currentVisibleComponent.containerEl?.hide();
|
||||
this.currentVisibleComponent = null;
|
||||
} else {
|
||||
// Hide all components
|
||||
console.log(
|
||||
"[FluentComponent] Hiding all components",
|
||||
isInitialHide ? "(initial hide)" : ""
|
||||
);
|
||||
this.contentComponent?.containerEl.hide();
|
||||
this.forecastComponent?.containerEl.hide();
|
||||
this.tagsComponent?.containerEl.hide();
|
||||
this.projectsComponent?.containerEl.hide();
|
||||
this.reviewComponent?.containerEl.hide();
|
||||
this.habitComponent?.containerEl.hide();
|
||||
this.calendarComponent?.containerEl.hide();
|
||||
this.kanbanComponent?.containerEl.hide();
|
||||
this.ganttComponent?.containerEl.hide();
|
||||
this.viewComponentManager?.hideAllComponents();
|
||||
|
||||
// Hide two column views
|
||||
this.twoColumnViewComponents.forEach((component) => {
|
||||
component.containerEl.hide();
|
||||
});
|
||||
|
||||
// Hide legacy containers
|
||||
this.listContainer?.hide();
|
||||
this.treeContainer?.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a specific view
|
||||
*/
|
||||
switchView(
|
||||
viewId: string,
|
||||
tasks: Task[],
|
||||
filteredTasks: Task[],
|
||||
currentFilterState: RootFilterState | null,
|
||||
viewMode: ViewMode,
|
||||
project?: string | null
|
||||
): void {
|
||||
console.log("[FluentComponent] switchView called with:", viewId, "viewMode:", viewMode);
|
||||
|
||||
// Remove transient overlays (loading/error/empty) before showing components
|
||||
if (this.contentArea) {
|
||||
this.contentArea
|
||||
.querySelectorAll(
|
||||
".tg-v2-loading, .tg-v2-error-state, .tg-v2-empty-state"
|
||||
)
|
||||
.forEach((el) => el.remove());
|
||||
}
|
||||
|
||||
// Hide all components first
|
||||
console.log("[FluentComponent] Hiding all components");
|
||||
this.hideAllComponents();
|
||||
|
||||
// Check if current view supports multiple view modes and we're in a non-list mode
|
||||
const viewModes = this.getAvailableModesForView(viewId);
|
||||
|
||||
// If the current view mode is not available for this view, reset to first available or list
|
||||
if (viewModes.length > 0 && !viewModes.includes(viewMode)) {
|
||||
viewMode = viewModes[0];
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[FluentComponent] Is content-based view?",
|
||||
this.isContentBasedView(viewId),
|
||||
"View mode:",
|
||||
viewMode,
|
||||
"Available modes:",
|
||||
viewModes
|
||||
);
|
||||
|
||||
if (
|
||||
this.isContentBasedView(viewId) &&
|
||||
viewModes.length > 0 &&
|
||||
viewMode !== "list" &&
|
||||
viewMode !== "tree"
|
||||
) {
|
||||
// For content-based views in kanban/calendar mode, use special rendering
|
||||
console.log(
|
||||
"[FluentComponent] Using renderContentWithViewMode for non-list/tree mode:",
|
||||
viewMode
|
||||
);
|
||||
this.renderContentWithViewMode(viewId, tasks, filteredTasks, viewMode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get view configuration
|
||||
const viewConfig = getViewSettingOrDefault(this.plugin, viewId as any);
|
||||
|
||||
let targetComponent: any = null;
|
||||
let modeForComponent: string = viewId;
|
||||
|
||||
// Handle TwoColumn views
|
||||
if (viewConfig.specificConfig?.viewType === "twocolumn") {
|
||||
if (!this.twoColumnViewComponents.has(viewId)) {
|
||||
const twoColumnConfig =
|
||||
viewConfig.specificConfig as TwoColumnSpecificConfig;
|
||||
const twoColumnComponent = new TaskPropertyTwoColumnView(
|
||||
this.contentArea,
|
||||
this.app,
|
||||
this.plugin,
|
||||
twoColumnConfig,
|
||||
viewId
|
||||
);
|
||||
this.parentView.addChild(twoColumnComponent);
|
||||
|
||||
// Set up event handlers
|
||||
twoColumnComponent.onTaskSelected = (task) =>
|
||||
this.viewHandlers.onTaskSelected(task);
|
||||
twoColumnComponent.onTaskCompleted = (task) =>
|
||||
this.viewHandlers.onTaskCompleted(task);
|
||||
twoColumnComponent.onTaskContextMenu = (event, task) =>
|
||||
this.viewHandlers.onTaskContextMenu(event, task);
|
||||
|
||||
this.twoColumnViewComponents.set(viewId, twoColumnComponent);
|
||||
}
|
||||
|
||||
targetComponent = this.twoColumnViewComponents.get(viewId);
|
||||
} else {
|
||||
// Check special view types
|
||||
const specificViewType = viewConfig.specificConfig?.viewType;
|
||||
|
||||
// Check if it's a special view managed by ViewComponentManager
|
||||
if (this.viewComponentManager.isSpecialView(viewId)) {
|
||||
targetComponent =
|
||||
this.viewComponentManager.showComponent(viewId);
|
||||
} else if (
|
||||
specificViewType === "forecast" ||
|
||||
viewId === "forecast"
|
||||
) {
|
||||
targetComponent = this.forecastComponent;
|
||||
} else {
|
||||
// Standard view types
|
||||
switch (viewId) {
|
||||
case "habit":
|
||||
targetComponent = this.habitComponent;
|
||||
break;
|
||||
case "tags":
|
||||
targetComponent = this.tagsComponent;
|
||||
break;
|
||||
case "projects":
|
||||
// In V2, Projects is treated as a content-based view using global project filters
|
||||
targetComponent = this.contentComponent;
|
||||
modeForComponent = viewId;
|
||||
break;
|
||||
case "review":
|
||||
targetComponent = this.reviewComponent;
|
||||
break;
|
||||
case "calendar":
|
||||
targetComponent = this.calendarComponent;
|
||||
break;
|
||||
case "kanban":
|
||||
targetComponent = this.kanbanComponent;
|
||||
break;
|
||||
case "gantt":
|
||||
targetComponent = this.ganttComponent;
|
||||
break;
|
||||
case "inbox":
|
||||
case "today":
|
||||
case "upcoming":
|
||||
case "flagged":
|
||||
default:
|
||||
// These are handled by ContentComponent
|
||||
targetComponent = this.contentComponent;
|
||||
modeForComponent = viewId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[FluentComponent] Target component determined:",
|
||||
targetComponent?.constructor?.name
|
||||
);
|
||||
|
||||
if (targetComponent) {
|
||||
console.log(
|
||||
`[FluentComponent] Activating component for view ${viewId}:`,
|
||||
targetComponent.constructor.name
|
||||
);
|
||||
targetComponent.containerEl.show();
|
||||
this.currentVisibleComponent = targetComponent;
|
||||
|
||||
// Set view mode first for ContentComponent
|
||||
if (typeof targetComponent.setViewMode === "function") {
|
||||
console.log(
|
||||
`[FluentComponent] Setting view mode for ${viewId} to ${modeForComponent}`
|
||||
);
|
||||
targetComponent.setViewMode(modeForComponent as any, project);
|
||||
}
|
||||
|
||||
// Set tasks on the component
|
||||
if (typeof targetComponent.setTasks === "function") {
|
||||
// Special handling for components that need only all tasks (single parameter)
|
||||
if (viewId === "review" || viewId === "tags") {
|
||||
console.log(
|
||||
`[FluentComponent] Calling setTasks for ${viewId} with ALL tasks:`,
|
||||
tasks.length
|
||||
);
|
||||
targetComponent.setTasks(tasks);
|
||||
} else {
|
||||
// Use filtered tasks
|
||||
let filteredTasksLocal = [...filteredTasks];
|
||||
// Forecast view: remove badge-only items
|
||||
if (viewId === "forecast") {
|
||||
filteredTasksLocal = filteredTasksLocal.filter(
|
||||
(task) => !(task as any).badge
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
"[FluentComponent] Calling setTasks with filtered:",
|
||||
filteredTasksLocal.length,
|
||||
"all:",
|
||||
tasks.length
|
||||
);
|
||||
targetComponent.setTasks(filteredTasksLocal, tasks);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle updateTasks method for table view adapter
|
||||
if (typeof targetComponent.updateTasks === "function") {
|
||||
const filterOptions: any = {};
|
||||
if (
|
||||
currentFilterState &&
|
||||
currentFilterState.filterGroups &&
|
||||
currentFilterState.filterGroups.length > 0
|
||||
) {
|
||||
filterOptions.advancedFilter = currentFilterState;
|
||||
}
|
||||
|
||||
targetComponent.updateTasks(
|
||||
filterTasks(
|
||||
tasks,
|
||||
viewId as any,
|
||||
this.plugin,
|
||||
filterOptions
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Refresh review settings if needed
|
||||
if (
|
||||
viewId === "review" &&
|
||||
typeof targetComponent.refreshReviewSettings === "function"
|
||||
) {
|
||||
targetComponent.refreshReviewSettings();
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`[FluentComponent] No target component found for viewId: ${viewId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render content with specific view mode (list/tree/kanban/calendar)
|
||||
*/
|
||||
renderContentWithViewMode(
|
||||
viewId: string,
|
||||
tasks: Task[],
|
||||
filteredTasks: Task[],
|
||||
viewMode: ViewMode
|
||||
): void {
|
||||
console.log(
|
||||
"[FluentComponent] renderContentWithViewMode called, viewMode:",
|
||||
viewMode
|
||||
);
|
||||
|
||||
// Hide current component
|
||||
this.hideAllComponents();
|
||||
|
||||
// Based on the current view mode, show the appropriate component
|
||||
switch (viewMode) {
|
||||
case "list":
|
||||
case "tree":
|
||||
// Use ContentComponent for list and tree views
|
||||
if (!this.contentComponent) return;
|
||||
|
||||
this.contentComponent.containerEl.show();
|
||||
this.contentComponent.setViewMode(viewId as any);
|
||||
this.contentComponent.setIsTreeView(viewMode === "tree");
|
||||
|
||||
console.log(
|
||||
"[FluentComponent] Setting tasks to ContentComponent, filtered:",
|
||||
filteredTasks.length
|
||||
);
|
||||
this.contentComponent.setTasks(filteredTasks, tasks);
|
||||
this.currentVisibleComponent = this.contentComponent;
|
||||
break;
|
||||
|
||||
case "kanban":
|
||||
// Use KanbanComponent
|
||||
if (!this.kanbanComponent) return;
|
||||
|
||||
this.kanbanComponent.containerEl.show();
|
||||
|
||||
console.log(
|
||||
"[FluentComponent] Setting",
|
||||
filteredTasks.length,
|
||||
"tasks to kanban"
|
||||
);
|
||||
this.kanbanComponent.setTasks(filteredTasks);
|
||||
this.currentVisibleComponent = this.kanbanComponent;
|
||||
break;
|
||||
|
||||
case "calendar":
|
||||
// Use CalendarComponent
|
||||
console.log(
|
||||
"[FluentComponent] Calendar mode in renderContentWithViewMode"
|
||||
);
|
||||
if (!this.calendarComponent) {
|
||||
console.log("[FluentComponent] No calendar component available!");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[FluentComponent] Showing calendar component");
|
||||
this.calendarComponent.containerEl.show();
|
||||
|
||||
console.log(
|
||||
"[FluentComponent] Setting",
|
||||
filteredTasks.length,
|
||||
"tasks to calendar"
|
||||
);
|
||||
this.calendarComponent.setTasks(filteredTasks);
|
||||
this.currentVisibleComponent = this.calendarComponent;
|
||||
console.log("[FluentComponent] Calendar mode setup complete");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh current view data without full re-render
|
||||
*/
|
||||
refreshCurrentViewData(
|
||||
viewId: string,
|
||||
tasks: Task[],
|
||||
filteredTasks: Task[],
|
||||
viewMode: ViewMode
|
||||
): void {
|
||||
// Content-based views (list/tree/kanban/calendar)
|
||||
if (this.isContentBasedView(viewId)) {
|
||||
switch (viewMode) {
|
||||
case "kanban":
|
||||
this.kanbanComponent?.setTasks?.(filteredTasks);
|
||||
break;
|
||||
case "calendar":
|
||||
this.calendarComponent?.setTasks?.(filteredTasks);
|
||||
break;
|
||||
case "tree":
|
||||
case "list":
|
||||
default:
|
||||
this.contentComponent?.setTasks?.(
|
||||
filteredTasks,
|
||||
tasks,
|
||||
true
|
||||
);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Special/other views
|
||||
if (this.viewComponentManager?.isSpecialView(viewId)) {
|
||||
const comp: any = (
|
||||
this.viewComponentManager as any
|
||||
).getOrCreateComponent(viewId);
|
||||
if (comp?.updateTasks) {
|
||||
comp.updateTasks(filteredTasks);
|
||||
} else if (comp?.setTasks) {
|
||||
comp.setTasks(filteredTasks, tasks);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Direct known components fallback
|
||||
const mapping: Record<string, any> = {
|
||||
forecast: this.forecastComponent,
|
||||
tags: this.tagsComponent,
|
||||
projects: this.contentComponent,
|
||||
review: this.reviewComponent,
|
||||
habit: this.habitComponent,
|
||||
gantt: this.ganttComponent,
|
||||
kanban: this.kanbanComponent,
|
||||
calendar: this.calendarComponent,
|
||||
};
|
||||
|
||||
const target: any = (mapping as any)[viewId];
|
||||
if (target?.setTasks) {
|
||||
if (viewId === "projects" || this.isContentBasedView(viewId)) {
|
||||
target.setTasks(filteredTasks, tasks, true);
|
||||
} else if (viewId === "review" || viewId === "tags") {
|
||||
target.setTasks(tasks);
|
||||
} else {
|
||||
target.setTasks(filteredTasks);
|
||||
}
|
||||
} else if (target?.updateTasks) {
|
||||
target.updateTasks(filteredTasks);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render loading state
|
||||
*/
|
||||
renderLoadingState(): void {
|
||||
console.log("[FluentComponent] renderLoadingState called");
|
||||
if (this.contentArea) {
|
||||
// Remove existing loading overlays
|
||||
this.contentArea
|
||||
.querySelectorAll(".tg-v2-loading")
|
||||
.forEach((el) => el.remove());
|
||||
|
||||
const loadingEl = this.contentArea.createDiv({
|
||||
cls: "tg-v2-loading",
|
||||
});
|
||||
loadingEl.createDiv({ cls: "tg-v2-spinner" });
|
||||
loadingEl.createDiv({
|
||||
cls: "tg-v2-loading-text",
|
||||
text: t("Loading tasks..."),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render error state
|
||||
*/
|
||||
renderErrorState(errorMessage: string, onRetry: () => void): void {
|
||||
if (this.contentArea) {
|
||||
this.contentArea
|
||||
.querySelectorAll(".tg-v2-error-state")
|
||||
.forEach((el) => el.remove());
|
||||
|
||||
const errorEl = this.contentArea.createDiv({
|
||||
cls: "tg-v2-error-state",
|
||||
});
|
||||
const errorIcon = errorEl.createDiv({ cls: "tg-v2-error-icon" });
|
||||
setIcon(errorIcon, "alert-triangle");
|
||||
|
||||
errorEl.createDiv({
|
||||
cls: "tg-v2-error-title",
|
||||
text: t("Failed to load tasks"),
|
||||
});
|
||||
|
||||
errorEl.createDiv({
|
||||
cls: "tg-v2-error-message",
|
||||
text: errorMessage || t("An unexpected error occurred"),
|
||||
});
|
||||
|
||||
const retryBtn = errorEl.createEl("button", {
|
||||
cls: "tg-v2-button tg-v2-button-primary",
|
||||
text: t("Retry"),
|
||||
});
|
||||
|
||||
retryBtn.addEventListener("click", onRetry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render empty state
|
||||
*/
|
||||
renderEmptyState(): void {
|
||||
if (this.contentArea) {
|
||||
this.contentArea
|
||||
.querySelectorAll(".tg-v2-empty-state")
|
||||
.forEach((el) => el.remove());
|
||||
|
||||
const emptyEl = this.contentArea.createDiv({
|
||||
cls: "tg-v2-empty-state",
|
||||
});
|
||||
const emptyIcon = emptyEl.createDiv({ cls: "tg-v2-empty-icon" });
|
||||
setIcon(emptyIcon, "inbox");
|
||||
|
||||
emptyEl.createDiv({
|
||||
cls: "tg-v2-empty-title",
|
||||
text: t("No tasks yet"),
|
||||
});
|
||||
|
||||
emptyEl.createDiv({
|
||||
cls: "tg-v2-empty-description",
|
||||
text: t(
|
||||
"Create your first task to get started with Task Genius"
|
||||
),
|
||||
});
|
||||
|
||||
const createBtn = emptyEl.createEl("button", {
|
||||
cls: "tg-v2-button tg-v2-button-primary",
|
||||
text: t("Create Task"),
|
||||
});
|
||||
|
||||
createBtn.addEventListener("click", () => {
|
||||
new QuickCaptureModal(this.app, this.plugin).open();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if view is content-based (supports multiple view modes)
|
||||
*/
|
||||
private isContentBasedView(viewId: string): boolean {
|
||||
const contentBasedViews = [
|
||||
"inbox",
|
||||
"today",
|
||||
"upcoming",
|
||||
"flagged",
|
||||
"projects",
|
||||
];
|
||||
return contentBasedViews.includes(viewId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available view modes for a specific view
|
||||
*/
|
||||
getAvailableModesForView(viewId: string): ViewMode[] {
|
||||
// Check for special two-column views
|
||||
const viewConfig = getViewSettingOrDefault(this.plugin, viewId as any);
|
||||
if (viewConfig?.specificConfig?.viewType === "twocolumn") {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check for special views managed by ViewComponentManager
|
||||
if (this.viewComponentManager?.isSpecialView(viewId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Return the configured modes for the view, or empty array
|
||||
return VIEW_MODE_CONFIG[viewId] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up on unload
|
||||
*/
|
||||
onunload(): void {
|
||||
// Components will be cleaned up by parent Component lifecycle
|
||||
super.onunload();
|
||||
}
|
||||
}
|
||||
327
src/experimental/v2/managers/FluentDataManager.ts
Normal file
327
src/experimental/v2/managers/FluentDataManager.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
import { Component, debounce } from "obsidian";
|
||||
import TaskProgressBarPlugin from "@/index";
|
||||
import { Task } from "@/types/task";
|
||||
import { filterTasks } from "@/utils/task/task-filter-utils";
|
||||
import { RootFilterState } from "@/components/features/task/filter/ViewTaskFilter";
|
||||
import { isDataflowEnabled } from "@/dataflow/createDataflow";
|
||||
import { Events, on } from "@/dataflow/events/Events";
|
||||
|
||||
/**
|
||||
* FluentDataManager - Stateless data loading and filtering executor
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Load tasks from dataflow or preloaded cache (returns via callback)
|
||||
* - Apply filters to tasks (pure function, returns filtered tasks)
|
||||
* - Register dataflow event listeners for real-time updates
|
||||
* - Schedule and batch updates to prevent rapid re-renders
|
||||
*
|
||||
* NOTE: This manager is STATELESS - it does not hold tasks or filtered tasks.
|
||||
* All state is managed by TaskViewV2, this manager only executes operations.
|
||||
*/
|
||||
export class FluentDataManager extends Component {
|
||||
// Callbacks
|
||||
private onTasksLoaded?: (tasks: Task[], error: string | null) => void;
|
||||
private onLoadingStateChanged?: (isLoading: boolean) => void;
|
||||
private onUpdateNeeded?: (source: string) => void;
|
||||
|
||||
constructor(
|
||||
private plugin: TaskProgressBarPlugin,
|
||||
private getCurrentViewId: () => string,
|
||||
private getCurrentFilterState: () => {
|
||||
liveFilterState: RootFilterState | null;
|
||||
currentFilterState: RootFilterState | null;
|
||||
viewStateFilters: any;
|
||||
selectedProject: string | undefined;
|
||||
searchQuery: string;
|
||||
filterInputValue: string;
|
||||
},
|
||||
private isInitializing: () => boolean
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callbacks for data operations
|
||||
*/
|
||||
setCallbacks(callbacks: {
|
||||
onTasksLoaded?: (tasks: Task[], error: string | null) => void;
|
||||
onLoadingStateChanged?: (isLoading: boolean) => void;
|
||||
onUpdateNeeded?: (source: string) => void;
|
||||
}) {
|
||||
this.onTasksLoaded = callbacks.onTasksLoaded;
|
||||
this.onLoadingStateChanged = callbacks.onLoadingStateChanged;
|
||||
this.onUpdateNeeded = callbacks.onUpdateNeeded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load tasks from dataflow or preloaded cache
|
||||
* Returns loaded tasks via callback
|
||||
*/
|
||||
async loadTasks(showLoading = true): Promise<void> {
|
||||
try {
|
||||
console.log("[FluentData] loadTasks started, showLoading:", showLoading);
|
||||
|
||||
// Notify loading state
|
||||
if (showLoading && !this.isInitializing()) {
|
||||
console.log("[FluentData] Notifying loading state");
|
||||
this.onLoadingStateChanged?.(true);
|
||||
}
|
||||
|
||||
let loadedTasks: Task[] = [];
|
||||
|
||||
if (this.plugin.dataflowOrchestrator) {
|
||||
console.log("[FluentData] Using dataflow orchestrator to load tasks");
|
||||
const queryAPI = this.plugin.dataflowOrchestrator.getQueryAPI();
|
||||
console.log("[FluentData] Getting all tasks from queryAPI...");
|
||||
loadedTasks = await queryAPI.getAllTasks();
|
||||
console.log(`[FluentData] Loaded ${loadedTasks.length} tasks from dataflow`);
|
||||
} else {
|
||||
console.log("[FluentData] Dataflow not available, using preloaded tasks");
|
||||
loadedTasks = this.plugin.preloadedTasks || [];
|
||||
console.log(`[FluentData] Loaded ${loadedTasks.length} preloaded tasks`);
|
||||
}
|
||||
|
||||
// Return loaded tasks via callback
|
||||
this.onTasksLoaded?.(loadedTasks, null);
|
||||
} catch (error) {
|
||||
console.error("[FluentData] Failed to load tasks:", error);
|
||||
const errorMessage = (error as Error).message || "Failed to load tasks";
|
||||
// Return error via callback
|
||||
this.onTasksLoaded?.([], errorMessage);
|
||||
} finally {
|
||||
// Always notify loading complete
|
||||
console.log("[FluentData] loadTasks complete");
|
||||
this.onLoadingStateChanged?.(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters to tasks (pure function - returns filtered tasks)
|
||||
* @param tasks - All tasks to filter
|
||||
* @returns Filtered tasks based on current filter state
|
||||
*/
|
||||
applyFilters(tasks: Task[]): Task[] {
|
||||
const viewId = this.getCurrentViewId();
|
||||
const filterState = this.getCurrentFilterState();
|
||||
|
||||
// Build filter options
|
||||
const filterOptions: any = {
|
||||
textQuery: filterState.filterInputValue || filterState.searchQuery || "",
|
||||
};
|
||||
|
||||
// Apply advanced filters from the filter popover/modal
|
||||
if (
|
||||
filterState.currentFilterState &&
|
||||
filterState.currentFilterState.filterGroups &&
|
||||
filterState.currentFilterState.filterGroups.length > 0
|
||||
) {
|
||||
console.log("[FluentData] Applying advanced filters");
|
||||
filterOptions.advancedFilter = filterState.currentFilterState;
|
||||
}
|
||||
|
||||
// If there are additional V2-specific filters from the filter panel, pass them
|
||||
if (
|
||||
filterState.viewStateFilters &&
|
||||
Object.keys(filterState.viewStateFilters).length > 0
|
||||
) {
|
||||
filterOptions.v2Filters = filterState.viewStateFilters;
|
||||
}
|
||||
|
||||
// Global project filter - Skip for Inbox view (Inbox tasks don't have projects by definition)
|
||||
if (filterState.selectedProject && viewId !== "inbox") {
|
||||
filterOptions.v2Filters = {
|
||||
...(filterOptions.v2Filters || {}),
|
||||
project: filterState.selectedProject,
|
||||
};
|
||||
}
|
||||
|
||||
// Use the existing filterTasks utility which handles all view-specific logic
|
||||
let filteredTasks = filterTasks(
|
||||
tasks,
|
||||
viewId as any,
|
||||
this.plugin,
|
||||
filterOptions
|
||||
);
|
||||
|
||||
// Apply additional V2-specific filters if needed
|
||||
if (filterOptions.v2Filters) {
|
||||
filteredTasks = this.applyV2Filters(filteredTasks, filterOptions.v2Filters);
|
||||
}
|
||||
|
||||
console.log(`[FluentData] Filtered ${filteredTasks.length} tasks from ${tasks.length} total`);
|
||||
|
||||
return filteredTasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply V2-specific filters (pure function - returns filtered tasks)
|
||||
* @param tasks - Tasks to filter
|
||||
* @param filters - V2 filter configuration
|
||||
* @returns Filtered tasks
|
||||
*/
|
||||
private applyV2Filters(tasks: Task[], filters: any): Task[] {
|
||||
const viewId = this.getCurrentViewId();
|
||||
let result = [...tasks]; // Copy array to avoid mutation
|
||||
|
||||
// Status filter
|
||||
if (filters.status && filters.status !== "all") {
|
||||
switch (filters.status) {
|
||||
case "active":
|
||||
result = result.filter((task) => !task.completed);
|
||||
break;
|
||||
case "completed":
|
||||
result = result.filter((task) => task.completed);
|
||||
break;
|
||||
case "overdue":
|
||||
result = result.filter((task) => {
|
||||
if (task.completed || !task.metadata?.dueDate) return false;
|
||||
return new Date(task.metadata.dueDate) < new Date();
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority filter
|
||||
if (filters.priority && filters.priority !== "all") {
|
||||
result = result.filter((task) => {
|
||||
const taskPriority = task.metadata?.priority || 0;
|
||||
const filterPriority =
|
||||
typeof filters.priority === "string"
|
||||
? parseInt(filters.priority)
|
||||
: filters.priority;
|
||||
return taskPriority === filterPriority;
|
||||
});
|
||||
}
|
||||
|
||||
// Project filter - Skip for Inbox view
|
||||
if (filters.project && viewId !== "inbox") {
|
||||
result = result.filter(
|
||||
(task) => task.metadata?.project === filters.project
|
||||
);
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if (filters.tags && filters.tags.length > 0) {
|
||||
result = result.filter((task) => {
|
||||
if (!task.metadata?.tags) return false;
|
||||
return filters.tags!.some((tag: string) =>
|
||||
task.metadata!.tags!.includes(tag)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if (filters.dateRange) {
|
||||
if (filters.dateRange.start) {
|
||||
result = result.filter((task) => {
|
||||
if (!task.metadata?.dueDate) return false;
|
||||
return (
|
||||
new Date(task.metadata.dueDate) >= filters.dateRange!.start!
|
||||
);
|
||||
});
|
||||
}
|
||||
if (filters.dateRange.end) {
|
||||
result = result.filter((task) => {
|
||||
if (!task.metadata?.dueDate) return false;
|
||||
return (
|
||||
new Date(task.metadata.dueDate) <= filters.dateRange!.end!
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Assignee filter
|
||||
if (filters.assignee) {
|
||||
result = result.filter(
|
||||
(task) => task.metadata?.assignee === filters.assignee
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register dataflow event listeners for real-time updates
|
||||
* Notifies parent via onUpdateNeeded callback
|
||||
*/
|
||||
async registerDataflowListeners(): Promise<void> {
|
||||
// Add debounced view update to prevent rapid successive refreshes
|
||||
const debouncedViewUpdate = debounce(async () => {
|
||||
console.log("[FluentData] debouncedViewUpdate triggered");
|
||||
if (!this.isInitializing()) {
|
||||
// Load tasks and notify parent
|
||||
await this.loadTasks(false);
|
||||
// Notify parent that data changed
|
||||
this.onUpdateNeeded?.("dataflow");
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Add debounced filter application
|
||||
const debouncedApplyFilter = debounce(() => {
|
||||
if (!this.isInitializing()) {
|
||||
this.onUpdateNeeded?.("filter-changed");
|
||||
}
|
||||
}, 400);
|
||||
|
||||
// Register dataflow event listeners
|
||||
if (
|
||||
isDataflowEnabled(this.plugin) &&
|
||||
this.plugin.dataflowOrchestrator
|
||||
) {
|
||||
// Listen for cache ready event
|
||||
this.registerEvent(
|
||||
on(this.plugin.app, Events.CACHE_READY, async () => {
|
||||
await this.loadTasks();
|
||||
this.onUpdateNeeded?.("cache-ready");
|
||||
})
|
||||
);
|
||||
|
||||
// Listen for task cache updates
|
||||
this.registerEvent(
|
||||
on(this.plugin.app, Events.TASK_CACHE_UPDATED, debouncedViewUpdate)
|
||||
);
|
||||
} else {
|
||||
// Legacy event support
|
||||
this.registerEvent(
|
||||
this.plugin.app.workspace.on(
|
||||
"task-genius:task-cache-updated",
|
||||
debouncedViewUpdate
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Listen for filter change events
|
||||
this.registerEvent(
|
||||
this.plugin.app.workspace.on(
|
||||
"task-genius:filter-changed",
|
||||
(filterState: RootFilterState, leafId?: string) => {
|
||||
// Only update if it's from a live filter component
|
||||
if (
|
||||
!leafId ||
|
||||
(!leafId.startsWith("view-config-") && leafId !== "global-filter")
|
||||
) {
|
||||
console.log("[FluentData] Filter changed, notifying update needed");
|
||||
debouncedApplyFilter();
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get current filter count for badge display
|
||||
*/
|
||||
getActiveFilterCount(): number {
|
||||
const filterState = this.getCurrentFilterState();
|
||||
let count = 0;
|
||||
|
||||
if (filterState.searchQuery || filterState.filterInputValue) count++;
|
||||
if (filterState.selectedProject) count++;
|
||||
if (filterState.currentFilterState?.filterGroups?.length) {
|
||||
count += filterState.currentFilterState.filterGroups.length;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
130
src/experimental/v2/managers/FluentGestureManager.ts
Normal file
130
src/experimental/v2/managers/FluentGestureManager.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { Component, Platform } from "obsidian";
|
||||
|
||||
/**
|
||||
* FluentGestureManager - Manages mobile touch gestures
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Edge swipe to open drawer (from left edge)
|
||||
* - Swipe left to close drawer (when open)
|
||||
* - Touch event handling with vertical scroll detection
|
||||
*/
|
||||
export class FluentGestureManager extends Component {
|
||||
// Touch gesture tracking
|
||||
private touchStartX = 0;
|
||||
private touchStartY = 0;
|
||||
private touchCurrentX = 0;
|
||||
private isSwiping = false;
|
||||
private swipeThreshold = 50;
|
||||
|
||||
// Callbacks
|
||||
private onOpenDrawer?: () => void;
|
||||
private onCloseDrawer?: () => void;
|
||||
private getIsMobileDrawerOpen?: () => boolean;
|
||||
|
||||
constructor(
|
||||
private rootContainerEl: HTMLElement
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set drawer callbacks
|
||||
*/
|
||||
setDrawerCallbacks(callbacks: {
|
||||
onOpenDrawer: () => void;
|
||||
onCloseDrawer: () => void;
|
||||
getIsMobileDrawerOpen: () => boolean;
|
||||
}): void {
|
||||
this.onOpenDrawer = callbacks.onOpenDrawer;
|
||||
this.onCloseDrawer = callbacks.onCloseDrawer;
|
||||
this.getIsMobileDrawerOpen = callbacks.getIsMobileDrawerOpen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize mobile swipe gestures for drawer
|
||||
*/
|
||||
initializeMobileSwipeGestures(): void {
|
||||
if (!Platform.isPhone) return;
|
||||
|
||||
// Edge swipe to open drawer
|
||||
this.registerDomEvent(document, "touchstart", (e: TouchEvent) => {
|
||||
const isMobileDrawerOpen = this.getIsMobileDrawerOpen?.() ?? false;
|
||||
|
||||
if (isMobileDrawerOpen) {
|
||||
// Track for swipe-to-close when drawer is open
|
||||
const touch = e.touches[0];
|
||||
this.touchStartX = touch.clientX;
|
||||
this.touchStartY = touch.clientY;
|
||||
this.isSwiping = true;
|
||||
} else {
|
||||
// Check if touch started from left edge
|
||||
const touch = e.touches[0];
|
||||
if (touch.clientX < 20) {
|
||||
// 20px edge detection zone
|
||||
this.touchStartX = touch.clientX;
|
||||
this.touchStartY = touch.clientY;
|
||||
this.isSwiping = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.registerDomEvent(document, "touchmove", (e: TouchEvent) => {
|
||||
if (!this.isSwiping) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
this.touchCurrentX = touch.clientX;
|
||||
const deltaX = this.touchCurrentX - this.touchStartX;
|
||||
const deltaY = Math.abs(touch.clientY - this.touchStartY);
|
||||
|
||||
// Check if horizontal swipe (not vertical scroll)
|
||||
if (deltaY > 50) {
|
||||
this.isSwiping = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const isMobileDrawerOpen = this.getIsMobileDrawerOpen?.() ?? false;
|
||||
|
||||
if (!isMobileDrawerOpen && deltaX > this.swipeThreshold) {
|
||||
// Swipe right from edge - open drawer
|
||||
this.onOpenDrawer?.();
|
||||
this.isSwiping = false;
|
||||
} else if (
|
||||
isMobileDrawerOpen &&
|
||||
deltaX < -this.swipeThreshold
|
||||
) {
|
||||
// Swipe left when drawer is open - close it
|
||||
const sidebarEl = this.rootContainerEl?.querySelector(
|
||||
".tg-v2-sidebar-container"
|
||||
);
|
||||
if (sidebarEl) {
|
||||
const sidebarRect = sidebarEl.getBoundingClientRect();
|
||||
// Only close if swipe started on the sidebar
|
||||
if (this.touchStartX < sidebarRect.right) {
|
||||
this.onCloseDrawer?.();
|
||||
this.isSwiping = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.registerDomEvent(document, "touchend", () => {
|
||||
this.isSwiping = false;
|
||||
this.touchStartX = 0;
|
||||
this.touchCurrentX = 0;
|
||||
});
|
||||
|
||||
this.registerDomEvent(document, "touchcancel", () => {
|
||||
this.isSwiping = false;
|
||||
this.touchStartX = 0;
|
||||
this.touchCurrentX = 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up on unload
|
||||
*/
|
||||
onunload(): void {
|
||||
// Event listeners will be cleaned up by Component lifecycle
|
||||
super.onunload();
|
||||
}
|
||||
}
|
||||
609
src/experimental/v2/managers/FluentLayoutManager.ts
Normal file
609
src/experimental/v2/managers/FluentLayoutManager.ts
Normal file
|
|
@ -0,0 +1,609 @@
|
|||
import { Component, Platform, ButtonComponent, WorkspaceLeaf, ItemView } from "obsidian";
|
||||
import { App } from "obsidian";
|
||||
import TaskProgressBarPlugin from "@/index";
|
||||
import { V2Sidebar } from "../components/V2Sidebar";
|
||||
import { TaskDetailsComponent } from "@/components/features/task/view/details";
|
||||
import { Task } from "@/types/task";
|
||||
import { t } from "@/translations/helper";
|
||||
import { TG_LEFT_SIDEBAR_VIEW_TYPE } from "../views/LeftSidebarView";
|
||||
import { QuickCaptureModal } from "@/components/features/quick-capture/modals/QuickCaptureModal";
|
||||
import {
|
||||
ViewTaskFilterPopover,
|
||||
ViewTaskFilterModal,
|
||||
} from "@/components/features/task/filter";
|
||||
import { RootFilterState } from "@/components/features/task/filter/ViewTaskFilter";
|
||||
|
||||
/**
|
||||
* FluentLayoutManager - Manages layout, sidebar, and details panel
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Sidebar toggle (supports both leaves and non-leaves mode)
|
||||
* - Details panel visibility (supports both leaves and non-leaves mode)
|
||||
* - Mobile drawer management
|
||||
* - Responsive layout adjustments
|
||||
* - Header buttons (sidebar toggle, task count)
|
||||
*
|
||||
* KEY ARCHITECTURAL CONSIDERATION:
|
||||
* This manager handles TWO distinct modes:
|
||||
* 1. Leaves Mode (useWorkspaceSideLeaves: true): Sidebar/Details in separate workspace leaves
|
||||
* 2. Non-Leaves Mode: Sidebar/Details embedded in main view
|
||||
*/
|
||||
export class FluentLayoutManager extends Component {
|
||||
// Layout state
|
||||
public isSidebarCollapsed = false;
|
||||
public isDetailsVisible = false;
|
||||
public isMobileDrawerOpen = false;
|
||||
|
||||
// UI elements
|
||||
private sidebarToggleBtn: HTMLElement | null = null;
|
||||
private drawerOverlay: HTMLElement | null = null;
|
||||
private detailsToggleBtn: HTMLElement | null = null;
|
||||
private mobileDetailsOverlayHandler?: (e: MouseEvent) => void;
|
||||
|
||||
// Components (non-leaves mode only)
|
||||
public sidebar: V2Sidebar | null = null;
|
||||
public detailsComponent: TaskDetailsComponent | null = null;
|
||||
|
||||
// Callbacks
|
||||
private onSidebarNavigate?: (viewId: string) => void;
|
||||
private onProjectSelect?: (projectId: string) => void;
|
||||
private onTaskToggleComplete?: (task: Task) => void;
|
||||
private onTaskEdit?: (task: Task) => void;
|
||||
private onTaskUpdate?: (originalTask: Task, updatedTask: Task) => Promise<void>;
|
||||
private onFilterReset?: () => void;
|
||||
private getLiveFilterState?: () => RootFilterState | null;
|
||||
private leaf: WorkspaceLeaf;
|
||||
|
||||
constructor(
|
||||
private app: App,
|
||||
private plugin: TaskProgressBarPlugin,
|
||||
private view: ItemView,
|
||||
private rootContainerEl: HTMLElement,
|
||||
private headerEl: HTMLElement,
|
||||
private titleEl: HTMLElement,
|
||||
private getTaskCount: () => number
|
||||
) {
|
||||
super();
|
||||
|
||||
this.leaf = view.leaf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set navigation callback
|
||||
*/
|
||||
setOnSidebarNavigate(callback: (viewId: string) => void): void {
|
||||
this.onSidebarNavigate = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set project select callback
|
||||
*/
|
||||
setOnProjectSelect(callback: (projectId: string) => void): void {
|
||||
this.onProjectSelect = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set task callbacks for details component
|
||||
*/
|
||||
setTaskCallbacks(callbacks: {
|
||||
onTaskToggleComplete: (task: Task) => void;
|
||||
onTaskEdit: (task: Task) => void;
|
||||
onTaskUpdate: (originalTask: Task, updatedTask: Task) => Promise<void>;
|
||||
}): void {
|
||||
this.onTaskToggleComplete = callbacks.onTaskToggleComplete;
|
||||
this.onTaskEdit = callbacks.onTaskEdit;
|
||||
this.onTaskUpdate = callbacks.onTaskUpdate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set filter callbacks
|
||||
*/
|
||||
setFilterCallbacks(callbacks: {
|
||||
onFilterReset: () => void;
|
||||
getLiveFilterState: () => RootFilterState | null;
|
||||
}): void {
|
||||
this.onFilterReset = callbacks.onFilterReset;
|
||||
this.getLiveFilterState = callbacks.getLiveFilterState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if using workspace side leaves mode
|
||||
*/
|
||||
private useSideLeaves(): boolean {
|
||||
return !!(
|
||||
(this.plugin.settings.experimental as any)?.v2Config
|
||||
?.useWorkspaceSideLeaves
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize sidebar (non-leaves mode only)
|
||||
*/
|
||||
initializeSidebar(containerEl: HTMLElement): void {
|
||||
if (this.useSideLeaves()) {
|
||||
containerEl.hide();
|
||||
console.log(
|
||||
"[FluentLayout] Using workspace side leaves: skip in-view sidebar"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// On mobile, start with sidebar completely hidden (drawer closed)
|
||||
const initialCollapsedState = Platform.isPhone
|
||||
? true
|
||||
: this.isSidebarCollapsed;
|
||||
|
||||
this.sidebar = new V2Sidebar(
|
||||
containerEl,
|
||||
this.plugin,
|
||||
(viewId) => {
|
||||
this.onSidebarNavigate?.(viewId);
|
||||
// Auto-close drawer on mobile after navigation
|
||||
if (Platform.isPhone) {
|
||||
this.closeMobileDrawer();
|
||||
}
|
||||
},
|
||||
(projectId) => {
|
||||
this.onProjectSelect?.(projectId);
|
||||
// Auto-close drawer on mobile after selection
|
||||
if (Platform.isPhone) {
|
||||
this.closeMobileDrawer();
|
||||
}
|
||||
},
|
||||
initialCollapsedState
|
||||
);
|
||||
|
||||
// Add sidebar as a child component for proper lifecycle management
|
||||
this.addChild(this.sidebar);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize details component (non-leaves mode only)
|
||||
*/
|
||||
initializeDetailsComponent(): void {
|
||||
if (this.useSideLeaves()) {
|
||||
console.log(
|
||||
"[FluentLayout] Using workspace side leaves: skip in-view details panel"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize details component (hidden by default)
|
||||
this.detailsComponent = new TaskDetailsComponent(
|
||||
this.rootContainerEl,
|
||||
this.app,
|
||||
this.plugin
|
||||
);
|
||||
this.addChild(this.detailsComponent);
|
||||
this.detailsComponent.load();
|
||||
|
||||
// Set up callbacks
|
||||
this.detailsComponent.onTaskToggleComplete = (task: Task) =>
|
||||
this.onTaskToggleComplete?.(task);
|
||||
this.detailsComponent.onTaskEdit = (task: Task) =>
|
||||
this.onTaskEdit?.(task);
|
||||
this.detailsComponent.onTaskUpdate = async (
|
||||
originalTask: Task,
|
||||
updatedTask: Task
|
||||
) => {
|
||||
await this.onTaskUpdate?.(originalTask, updatedTask);
|
||||
};
|
||||
this.detailsComponent.toggleDetailsVisibility = (visible: boolean) => {
|
||||
this.toggleDetailsVisibility(visible);
|
||||
};
|
||||
this.detailsComponent.setVisible(this.isDetailsVisible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create sidebar toggle button in header
|
||||
*/
|
||||
createSidebarToggle(): void {
|
||||
const headerBtns = !Platform.isPhone
|
||||
? (this.headerEl?.querySelector(
|
||||
".view-header-nav-buttons"
|
||||
) as HTMLElement | null)
|
||||
: (this.headerEl?.querySelector(".view-header-left") as HTMLElement);
|
||||
|
||||
if (!headerBtns) {
|
||||
console.warn("[FluentLayout] header buttons container not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const container = headerBtns.createDiv({
|
||||
cls: "panel-toggle-container",
|
||||
});
|
||||
|
||||
this.sidebarToggleBtn = container.createDiv({
|
||||
cls: "panel-toggle-btn",
|
||||
});
|
||||
|
||||
const btn = new ButtonComponent(this.sidebarToggleBtn);
|
||||
btn.setIcon(Platform.isPhone ? "menu" : "panel-left-dashed")
|
||||
.setTooltip(t("Toggle Sidebar"))
|
||||
.setClass("clickable-icon")
|
||||
.onClick(() => this.toggleSidebar());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create task count mark in title
|
||||
*/
|
||||
createTaskMark(): void {
|
||||
this.updateTaskMark();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task count in title
|
||||
*/
|
||||
updateTaskMark(): void {
|
||||
this.titleEl.setText(
|
||||
t("{{num}} Tasks", {
|
||||
interpolation: {
|
||||
num: this.getTaskCount(),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle sidebar visibility
|
||||
* Behavior differs based on mode:
|
||||
* - Leaves mode: Toggle workspace left split
|
||||
* - Non-leaves mode: Toggle internal sidebar component
|
||||
* - Mobile: Toggle drawer overlay
|
||||
*/
|
||||
toggleSidebar(): void {
|
||||
if (this.useSideLeaves()) {
|
||||
// In side-leaf mode, toggle the left sidebar split collapse state
|
||||
const ws = this.app.workspace;
|
||||
const leftSplit = ws.leftSplit;
|
||||
const isCollapsed = !!leftSplit?.collapsed;
|
||||
|
||||
if (isCollapsed) {
|
||||
// Expand and ensure our sidebar view
|
||||
leftSplit.expand();
|
||||
// Handle async ensureSideLeaf
|
||||
ws.ensureSideLeaf(TG_LEFT_SIDEBAR_VIEW_TYPE, "left", {active: false}).then(leftLeaf => {
|
||||
if (leftLeaf) {
|
||||
ws.revealLeaf?.(leftLeaf);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
leftSplit.collapse();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.isPhone) {
|
||||
// On mobile, toggle the drawer open/closed
|
||||
if (this.isMobileDrawerOpen) {
|
||||
this.closeMobileDrawer();
|
||||
} else {
|
||||
this.openMobileDrawer();
|
||||
}
|
||||
} else {
|
||||
// On desktop, toggle collapse state
|
||||
this.isSidebarCollapsed = !this.isSidebarCollapsed;
|
||||
this.sidebar?.setCollapsed(this.isSidebarCollapsed);
|
||||
this.rootContainerEl?.toggleClass(
|
||||
"v2-sidebar-collapsed",
|
||||
this.isSidebarCollapsed
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open mobile drawer
|
||||
*/
|
||||
openMobileDrawer(): void {
|
||||
this.isMobileDrawerOpen = true;
|
||||
this.rootContainerEl?.addClass("drawer-open");
|
||||
if (this.drawerOverlay) {
|
||||
this.drawerOverlay.style.display = "block";
|
||||
}
|
||||
// Show the sidebar
|
||||
this.sidebar?.setCollapsed(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close mobile drawer
|
||||
*/
|
||||
closeMobileDrawer(): void {
|
||||
this.isMobileDrawerOpen = false;
|
||||
this.rootContainerEl?.removeClass("drawer-open");
|
||||
if (this.drawerOverlay) {
|
||||
this.drawerOverlay.style.display = "none";
|
||||
}
|
||||
// Hide the sidebar
|
||||
this.sidebar?.setCollapsed(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up drawer overlay for mobile
|
||||
*/
|
||||
setupDrawerOverlay(layoutContainer: HTMLElement): void {
|
||||
if (!Platform.isPhone) return;
|
||||
|
||||
this.drawerOverlay = layoutContainer.createDiv({
|
||||
cls: "drawer-overlay",
|
||||
});
|
||||
this.drawerOverlay.style.display = "none";
|
||||
this.drawerOverlay.addEventListener("click", () => {
|
||||
this.closeMobileDrawer();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle details panel visibility
|
||||
* Behavior differs based on mode:
|
||||
* - Leaves mode: Toggle workspace right split
|
||||
* - Non-leaves mode: Toggle internal details component
|
||||
* - Mobile: Overlay with backdrop
|
||||
*/
|
||||
toggleDetailsVisibility(visible: boolean): void {
|
||||
this.isDetailsVisible = visible;
|
||||
|
||||
if (this.useSideLeaves()) {
|
||||
// In side-leaf mode, reveal/collapse the right details pane
|
||||
const ws = this.app.workspace;
|
||||
if (visible) {
|
||||
// Try to expand right split if it's collapsed
|
||||
if (
|
||||
ws.rightSplit?.collapsed
|
||||
) {
|
||||
ws.rightSplit.expand();
|
||||
}
|
||||
} else {
|
||||
ws.rightSplit.collapse();
|
||||
}
|
||||
|
||||
// Update header toggle visual state
|
||||
if (this.detailsToggleBtn) {
|
||||
this.detailsToggleBtn.toggleClass("is-active", visible);
|
||||
this.detailsToggleBtn.setAttribute(
|
||||
"aria-label",
|
||||
visible ? t("Hide Details") : t("Show Details")
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy/in-view mode
|
||||
this.rootContainerEl?.toggleClass("details-visible", visible);
|
||||
this.rootContainerEl?.toggleClass("details-hidden", !visible);
|
||||
|
||||
if (this.detailsComponent) {
|
||||
this.detailsComponent.setVisible(visible);
|
||||
}
|
||||
|
||||
if (this.detailsToggleBtn) {
|
||||
this.detailsToggleBtn.toggleClass("is-active", visible);
|
||||
this.detailsToggleBtn.setAttribute(
|
||||
"aria-label",
|
||||
visible ? t("Hide Details") : t("Show Details")
|
||||
);
|
||||
}
|
||||
|
||||
// On mobile, add click handler to overlay to close details
|
||||
if (Platform.isPhone && visible) {
|
||||
// Use setTimeout to avoid immediate close on open
|
||||
setTimeout(() => {
|
||||
const overlayClickHandler = (e: MouseEvent) => {
|
||||
// Check if click is on the overlay (pseudo-element area)
|
||||
const detailsEl = this.rootContainerEl?.querySelector(
|
||||
".task-details"
|
||||
);
|
||||
if (detailsEl && !detailsEl.contains(e.target as Node)) {
|
||||
this.toggleDetailsVisibility(false);
|
||||
document.removeEventListener("click", overlayClickHandler);
|
||||
}
|
||||
};
|
||||
document.addEventListener("click", overlayClickHandler);
|
||||
this.mobileDetailsOverlayHandler = overlayClickHandler;
|
||||
}, 100);
|
||||
} else if (
|
||||
Platform.isPhone &&
|
||||
!visible &&
|
||||
this.mobileDetailsOverlayHandler
|
||||
) {
|
||||
document.removeEventListener(
|
||||
"click",
|
||||
this.mobileDetailsOverlayHandler
|
||||
);
|
||||
delete this.mobileDetailsOverlayHandler;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store details toggle button reference
|
||||
*/
|
||||
setDetailsToggleBtn(btn: HTMLElement): void {
|
||||
this.detailsToggleBtn = btn;
|
||||
this.detailsToggleBtn.toggleClass("is-active", this.isDetailsVisible);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and auto-collapse sidebar on narrow screens (desktop only)
|
||||
*/
|
||||
checkAndCollapseSidebar(): void {
|
||||
// Skip auto-collapse on mobile, as we use drawer mode
|
||||
if (Platform.isPhone) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-collapse on narrow panes (desktop only)
|
||||
try {
|
||||
const width = this.leaf?.width ?? 0;
|
||||
if (width > 0 && width < 768) {
|
||||
this.isSidebarCollapsed = true;
|
||||
this.sidebar?.setCollapsed(true);
|
||||
this.rootContainerEl?.addClass("v2-sidebar-collapsed");
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle window resize
|
||||
*/
|
||||
onResize(): void {
|
||||
// Only check and collapse on desktop
|
||||
if (!Platform.isPhone) {
|
||||
this.checkAndCollapseSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show task details in details panel
|
||||
*/
|
||||
showTaskDetails(task: Task): void {
|
||||
if (this.useSideLeaves()) {
|
||||
// In leaves mode, emit event to side leaf details view
|
||||
// This will be handled by the main view's event emission
|
||||
return;
|
||||
}
|
||||
|
||||
this.detailsComponent?.showTaskDetails(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sidebar active item
|
||||
*/
|
||||
setSidebarActiveItem(viewId: string): void {
|
||||
this.sidebar?.setActiveItem(viewId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh sidebar project list
|
||||
*/
|
||||
refreshSidebarProjects(): void {
|
||||
this.sidebar?.projectList?.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active project in sidebar
|
||||
*/
|
||||
setActiveProject(projectId: string | null): void {
|
||||
this.sidebar?.projectList?.setActiveProject(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create action buttons in view header
|
||||
* - Details toggle button
|
||||
* - Quick capture button
|
||||
* - Filter button
|
||||
* - Reset filter button (conditional)
|
||||
*/
|
||||
createActionButtons(): void {
|
||||
// Details toggle button
|
||||
this.detailsToggleBtn = this.view.addAction(
|
||||
"panel-right-dashed",
|
||||
t("Details"),
|
||||
() => {
|
||||
this.toggleDetailsVisibility(!this.isDetailsVisible);
|
||||
}
|
||||
);
|
||||
|
||||
if (this.detailsToggleBtn) {
|
||||
this.detailsToggleBtn.toggleClass("panel-toggle-btn", true);
|
||||
this.detailsToggleBtn.toggleClass("is-active", this.isDetailsVisible);
|
||||
}
|
||||
|
||||
// Capture button
|
||||
this.view.addAction("notebook-pen", t("Capture"), () => {
|
||||
const modal = new QuickCaptureModal(
|
||||
this.app,
|
||||
this.plugin,
|
||||
{},
|
||||
true
|
||||
);
|
||||
modal.open();
|
||||
});
|
||||
|
||||
// Filter button
|
||||
this.view.addAction("filter", t("Filter"), (e: MouseEvent) => {
|
||||
if (Platform.isDesktop) {
|
||||
const popover = new ViewTaskFilterPopover(
|
||||
this.app,
|
||||
undefined,
|
||||
this.plugin
|
||||
);
|
||||
|
||||
// Set up filter state when opening
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
setTimeout(() => {
|
||||
const liveFilterState = this.getLiveFilterState?.();
|
||||
if (
|
||||
liveFilterState &&
|
||||
popover.taskFilterComponent
|
||||
) {
|
||||
popover.taskFilterComponent.loadFilterState(
|
||||
liveFilterState
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
popover.showAtPosition({x: e.clientX, y: e.clientY});
|
||||
} else {
|
||||
const modal = new ViewTaskFilterModal(
|
||||
this.app,
|
||||
this.leaf.id,
|
||||
this.plugin
|
||||
);
|
||||
|
||||
modal.open();
|
||||
|
||||
// Set initial filter state
|
||||
const liveFilterState = this.getLiveFilterState?.();
|
||||
if (liveFilterState && modal.taskFilterComponent) {
|
||||
setTimeout(() => {
|
||||
modal.taskFilterComponent.loadFilterState(liveFilterState);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update action buttons visibility
|
||||
this.updateActionButtons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update action buttons visibility (mainly Reset Filter button)
|
||||
*/
|
||||
updateActionButtons(): void {
|
||||
// Remove reset filter button if exists
|
||||
const resetButton = this.headerEl.querySelector(
|
||||
".view-action.task-filter-reset"
|
||||
);
|
||||
if (resetButton) {
|
||||
resetButton.remove();
|
||||
}
|
||||
|
||||
// Add reset filter button if there are active filters
|
||||
const liveFilterState = this.getLiveFilterState?.();
|
||||
if (
|
||||
liveFilterState &&
|
||||
liveFilterState.filterGroups &&
|
||||
liveFilterState.filterGroups.length > 0
|
||||
) {
|
||||
this.view.addAction("reset", t("Reset Filter"), () => {
|
||||
this.onFilterReset?.();
|
||||
}).addClass("task-filter-reset");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up mobile event listeners
|
||||
*/
|
||||
onunload(): void {
|
||||
if (Platform.isPhone && (this as any).mobileDetailsOverlayHandler) {
|
||||
document.removeEventListener(
|
||||
"click",
|
||||
(this as any).mobileDetailsOverlayHandler
|
||||
);
|
||||
delete (this as any).mobileDetailsOverlayHandler;
|
||||
}
|
||||
super.onunload();
|
||||
}
|
||||
}
|
||||
228
src/experimental/v2/managers/FluentWorkspaceStateManager.ts
Normal file
228
src/experimental/v2/managers/FluentWorkspaceStateManager.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import { Component, debounce, App } from "obsidian";
|
||||
import TaskProgressBarPlugin from "@/index";
|
||||
import { RootFilterState } from "@/components/features/task/filter/ViewTaskFilter";
|
||||
import { ViewMode } from "../components/V2TopNavigation";
|
||||
|
||||
/**
|
||||
* FluentWorkspaceStateManager - Manages workspace state persistence
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Save/restore workspace layout (filter state, view preferences)
|
||||
* - Workspace switching
|
||||
* - Filter state persistence to workspace overrides
|
||||
* - LocalStorage management for current workspace
|
||||
*/
|
||||
export class FluentWorkspaceStateManager extends Component {
|
||||
// Flag to prevent infinite loops during save
|
||||
private isSavingFilterState = false;
|
||||
|
||||
constructor(
|
||||
private app: App,
|
||||
private plugin: TaskProgressBarPlugin,
|
||||
private getWorkspaceId: () => string,
|
||||
private getCurrentViewId: () => string,
|
||||
private getViewState: () => {
|
||||
filters: any;
|
||||
selectedProject: string | undefined;
|
||||
searchQuery: string;
|
||||
viewMode: ViewMode;
|
||||
},
|
||||
private getCurrentFilterState: () => RootFilterState | null,
|
||||
private getLiveFilterState: () => RootFilterState | null
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save workspace layout (filter state and preferences)
|
||||
*/
|
||||
saveWorkspaceLayout(): void {
|
||||
const workspaceId = this.getWorkspaceId();
|
||||
if (!workspaceId) return;
|
||||
|
||||
// Save filter state
|
||||
this.saveFilterStateToWorkspace();
|
||||
|
||||
// Save current workspace ID to localStorage for persistence
|
||||
localStorage.setItem(
|
||||
"task-genius-v2-current-workspace",
|
||||
workspaceId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load workspace layout (filter state and preferences)
|
||||
*/
|
||||
loadWorkspaceLayout(): string | null {
|
||||
// Load current workspace from localStorage
|
||||
const savedCurrentWorkspace = localStorage.getItem(
|
||||
"task-genius-v2-current-workspace"
|
||||
);
|
||||
|
||||
if (savedCurrentWorkspace) {
|
||||
return savedCurrentWorkspace;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply workspace settings
|
||||
*/
|
||||
async applyWorkspaceSettings(): Promise<void> {
|
||||
const workspaceId = this.getWorkspaceId();
|
||||
if (!this.plugin.workspaceManager || !workspaceId) return;
|
||||
|
||||
const settings = this.plugin.workspaceManager.getEffectiveSettings(
|
||||
workspaceId
|
||||
);
|
||||
|
||||
// Workspace settings are now restored via restoreFilterStateFromWorkspace
|
||||
// This method is kept for future workspace-specific settings that are not filter-related
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different workspace
|
||||
*/
|
||||
async switchWorkspace(workspaceId: string): Promise<void> {
|
||||
// Save current workspace before switching
|
||||
this.saveWorkspaceLayout();
|
||||
|
||||
// Update workspace ID will be handled by caller
|
||||
// This method just handles the save/restore logic
|
||||
}
|
||||
|
||||
/**
|
||||
* Save filter state to workspace (debounced to avoid infinite loops)
|
||||
*/
|
||||
saveFilterStateToWorkspace = debounce(
|
||||
() => {
|
||||
const workspaceId = this.getWorkspaceId();
|
||||
const viewId = this.getCurrentViewId();
|
||||
|
||||
if (!this.plugin.workspaceManager || !workspaceId) return;
|
||||
|
||||
const effectiveSettings =
|
||||
this.plugin.workspaceManager.getEffectiveSettings(
|
||||
workspaceId
|
||||
);
|
||||
|
||||
// Save current filter state
|
||||
if (!effectiveSettings.v2FilterState) {
|
||||
effectiveSettings.v2FilterState = {};
|
||||
}
|
||||
|
||||
const viewState = this.getViewState();
|
||||
const currentFilterState = this.getCurrentFilterState();
|
||||
|
||||
// Build payload (do NOT persist ephemeral fields across workspaces)
|
||||
const payload = {
|
||||
filters: viewState.filters,
|
||||
selectedProject: viewState.selectedProject,
|
||||
advancedFilter: currentFilterState,
|
||||
viewMode: viewState.viewMode,
|
||||
};
|
||||
effectiveSettings.v2FilterState[viewId] = payload;
|
||||
|
||||
console.log("[FluentWorkspace] saveFilterStateToWorkspace", {
|
||||
workspaceId: workspaceId,
|
||||
viewId: viewId,
|
||||
searchQuery: viewState.searchQuery,
|
||||
selectedProject: viewState.selectedProject,
|
||||
hasAdvanced: !!currentFilterState,
|
||||
groups: (currentFilterState as any)?.filterGroups?.length ?? 0,
|
||||
});
|
||||
|
||||
// Use saveOverridesQuietly to avoid triggering SETTINGS_CHANGED event
|
||||
this.plugin.workspaceManager
|
||||
.saveOverridesQuietly(workspaceId, effectiveSettings)
|
||||
.then(() =>
|
||||
console.log("[FluentWorkspace] overrides saved quietly", {
|
||||
workspaceId: workspaceId,
|
||||
viewId: viewId,
|
||||
})
|
||||
)
|
||||
.catch((e) =>
|
||||
console.warn("[FluentWorkspace] failed to save overrides", e)
|
||||
);
|
||||
},
|
||||
500,
|
||||
true
|
||||
);
|
||||
|
||||
/**
|
||||
* Restore filter state from workspace
|
||||
*/
|
||||
restoreFilterStateFromWorkspace(): {
|
||||
filters: any;
|
||||
selectedProject: string | undefined;
|
||||
advancedFilter: RootFilterState | null;
|
||||
viewMode: ViewMode;
|
||||
shouldClearSearch: boolean;
|
||||
} | null {
|
||||
const workspaceId = this.getWorkspaceId();
|
||||
const viewId = this.getCurrentViewId();
|
||||
|
||||
if (!this.plugin.workspaceManager || !workspaceId) return null;
|
||||
|
||||
const effectiveSettings =
|
||||
this.plugin.workspaceManager.getEffectiveSettings(workspaceId);
|
||||
|
||||
const saved =
|
||||
effectiveSettings.v2FilterState?.[viewId] ?? null;
|
||||
|
||||
console.log("[FluentWorkspace] restoreFilterStateFromWorkspace", {
|
||||
workspaceId: workspaceId,
|
||||
viewId: viewId,
|
||||
hasSaved: !!saved,
|
||||
savedSearch: saved?.searchQuery ?? "",
|
||||
savedProject: saved?.selectedProject ?? null,
|
||||
hasAdvanced: !!saved?.advancedFilter,
|
||||
groups: saved?.advancedFilter?.filterGroups?.length ?? 0,
|
||||
});
|
||||
|
||||
if (saved) {
|
||||
const savedState = saved;
|
||||
|
||||
return {
|
||||
filters: savedState.filters || {},
|
||||
selectedProject: savedState.selectedProject,
|
||||
advancedFilter: savedState.advancedFilter || null,
|
||||
viewMode: savedState.viewMode || "list",
|
||||
shouldClearSearch: true, // Always clear searchQuery on workspace restore
|
||||
};
|
||||
} else {
|
||||
// No saved state for this view in this workspace
|
||||
return {
|
||||
filters: {},
|
||||
selectedProject: undefined,
|
||||
advancedFilter: null,
|
||||
viewMode: "list",
|
||||
shouldClearSearch: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get saved workspace ID from localStorage
|
||||
*/
|
||||
getSavedWorkspaceId(): string | null {
|
||||
return localStorage.getItem("task-genius-v2-current-workspace");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear workspace state from localStorage
|
||||
*/
|
||||
clearWorkspaceState(): void {
|
||||
localStorage.removeItem("task-genius-v2-current-workspace");
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up on unload
|
||||
*/
|
||||
onunload(): void {
|
||||
// Save state before unload
|
||||
this.saveWorkspaceLayout();
|
||||
super.onunload();
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ export interface V2ViewState {
|
|||
selectedProject?: string | null;
|
||||
viewMode: 'list' | 'kanban' | 'tree' | 'calendar';
|
||||
searchQuery?: string;
|
||||
filterInputValue?: string;
|
||||
filters?: any;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue