feat: support indicator for better status handling

This commit is contained in:
Quorafind 2025-11-06 21:58:30 +08:00
parent b1c0b8d72e
commit d36026c4c4
4 changed files with 570 additions and 4 deletions

View file

@ -733,6 +733,7 @@ export interface TaskProgressBarSettings {
useOnlyCountMarks: boolean;
onlyCountTaskMarks: string;
enableTaskStatusSwitcher: boolean;
enableIndicatorWithCheckbox: boolean; // Merge status indicator functionality with checkbox
enableCustomTaskMarks: boolean;
enableTextMarkInSourceMode: boolean;
enableCycleCompleteStatus: boolean; // Enable cycling through task statuses when clicking on task checkboxes
@ -931,6 +932,7 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
useOnlyCountMarks: false,
onlyCountTaskMarks: "x|X|>|/", // Default example
enableTaskStatusSwitcher: false,
enableIndicatorWithCheckbox: false, // Default to showing separate indicator
enableCustomTaskMarks: false,
enableTextMarkInSourceMode: false,
enableCycleCompleteStatus: false,
@ -1754,7 +1756,7 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
// Helper function to get view settings safely
export function getViewSettingOrDefault(
plugin: TaskProgressBarPlugin,
viewId: ViewMode,
viewId: ViewMode
): ViewConfig {
const viewConfiguration =
plugin.settings.viewConfiguration || DEFAULT_SETTINGS.viewConfiguration;
@ -1764,7 +1766,7 @@ export function getViewSettingOrDefault(
// Then check if it exists in default settings
const defaultConfig = DEFAULT_SETTINGS.viewConfiguration.find(
(v) => v.id === viewId,
(v) => v.id === viewId
);
// If neither exists, create a fallback default for custom views
@ -1796,7 +1798,7 @@ export function getViewSettingOrDefault(
? {
...(baseConfig.filterRules || {}), // Start with base's filterRules
...savedConfig.filterRules, // Override with saved filterRules properties
}
}
: baseConfig.filterRules || {}, // If no saved filterRules, use base's
// Merge specificConfig: Saved overrides default, default overrides base (which might be fallback without specificConfig)
// Ensure that the spread of savedConfig doesn't overwrite specificConfig object entirely if base has one and saved doesn't.
@ -1806,7 +1808,7 @@ export function getViewSettingOrDefault(
// If saved has specificConfig, merge it onto base's
...(baseConfig.specificConfig || {}),
...savedConfig.specificConfig,
}
}
: baseConfig.specificConfig, // Otherwise, just use base's specificConfig (could be undefined)
};

View file

@ -162,6 +162,14 @@ export class FluentComponentManager extends Component {
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);
},

View file

@ -570,6 +570,20 @@ export function renderTaskStatusSettingsTab(
});
});
if (settingTab.plugin.settings.enableTaskStatusSwitcher) {
new Setting(containerEl)
.setName(t("Show indicator with checkbox"))
.setDesc(
t(
"Show the status indicator directly next to the checkbox. When enabled, a indicator will be shown next to the checkbox."
)
)
.addToggle((toggle) => toggle.setValue(settingTab.plugin.settings.enableIndicatorWithCheckbox).onChange(async (value) => {
settingTab.plugin.settings.enableIndicatorWithCheckbox = value;
settingTab.applySettingsUpdate();
}));
}
if (settingTab.plugin.settings.enableTaskStatusSwitcher) {
new Setting(containerEl)
.setName(t("Task mark display style"))

View file

@ -0,0 +1,542 @@
import { Component, Menu } from "obsidian";
import TaskProgressBarPlugin from "@/index";
import { Task } from "@/types/task";
interface TaskStatusConfig {
cycle: string[];
marks: Record<string, string>;
excludeMarksFromCycle: string[];
}
const FALLBACK_STATUS_MARKS: Record<string, string> = {
TODO: " ",
DOING: "-",
"IN-PROGRESS": ">",
DONE: "x",
};
export interface TaskStatusIndicatorParams {
task: Task;
plugin: TaskProgressBarPlugin;
onStatusChange?: (previous: Task, updated: Task) => Promise<void> | void;
canInteract?: () => boolean;
}
interface StatusContext {
config: TaskStatusConfig;
currentMark: string;
statusName?: string;
displayName: string;
nextStatus?: string;
statusSlug: string;
isCompleted: boolean;
hasCycle: boolean;
}
export class TaskStatusIndicator extends Component {
private element: HTMLElement | null = null;
private statusTextEl: HTMLElement | null = null;
private readonly plugin: TaskProgressBarPlugin;
private readonly onStatusChange?: (
previous: Task,
updated: Task,
) => Promise<void> | void;
private readonly canInteract?: () => boolean;
private isProcessing = false;
private task: Task;
constructor({
task,
plugin,
onStatusChange,
canInteract,
}: TaskStatusIndicatorParams) {
super();
this.task = task;
this.plugin = plugin;
this.onStatusChange = onStatusChange;
this.canInteract = canInteract;
}
public render(container: HTMLElement): void {
if (this.element) {
this.element.detach();
}
this.element = container.createEl("span", {
cls: "task-status-indicator",
attr: {
role: "button",
tabindex: "0",
},
});
this.statusTextEl = this.element.createSpan({
cls: "task-status-indicator__text",
});
this.registerDomEvent(this.element, "click", (event) => {
event.preventDefault();
event.stopPropagation();
this.handleCycleRequest();
});
this.registerDomEvent(this.element, "contextmenu", (event) => {
event.preventDefault();
event.stopPropagation();
this.handleContextMenu(event);
});
this.registerDomEvent(this.element, "keydown", (event) => {
if (!(event instanceof KeyboardEvent)) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
this.handleCycleRequest();
}
if (event.key === "ContextMenu") {
event.preventDefault();
this.handleContextMenu(event);
}
if (event.shiftKey && event.key === "F10") {
event.preventDefault();
this.handleContextMenu(event);
}
});
this.refreshDisplay();
}
public updateTask(task: Task): void {
this.task = task;
this.refreshDisplay();
}
public cycle(): void {
this.handleCycleRequest();
}
private handleCycleRequest(): void {
if (this.isProcessing || !this.isInteractionAllowed()) {
return;
}
const context = this.buildStatusContext();
if (!context.hasCycle || !context.nextStatus) {
return;
}
this.applyStatus(context.nextStatus).catch(() => {
/* Errors are logged in applyStatus */
});
}
private handleContextMenu(event: MouseEvent | KeyboardEvent): void {
if (this.isProcessing) {
return;
}
const context = this.buildStatusContext();
if (context.config.cycle.length === 0) {
return;
}
const menu = new Menu();
const currentNormalized = context.statusName
? this.normalizeStatus(context.statusName)
: undefined;
for (const status of context.config.cycle) {
const normalized = this.normalizeStatus(status);
menu.addItem((item) => {
item.setTitle(status);
if (currentNormalized && currentNormalized === normalized) {
item.setChecked(true);
}
item.onClick(() => {
this.applyStatus(status).catch(() => {
/* Errors are logged in applyStatus */
});
});
});
}
if (event instanceof MouseEvent) {
menu.showAtMouseEvent(event);
} else if (this.element) {
const rect = this.element.getBoundingClientRect();
menu.showAtPosition({ x: rect.left, y: rect.bottom });
}
}
private async applyStatus(targetStatus: string): Promise<void> {
if (this.isProcessing || !this.isInteractionAllowed()) {
return;
}
const config = this.getStatusConfig();
const mark = this.getMarkForStatus(targetStatus, config);
if (mark === undefined) {
console.warn(
"[TaskStatusIndicator] Mark not found for status:",
targetStatus,
);
return;
}
const previousTask = this.task;
const optimisticTask = this.buildUpdatedTask(mark);
this.task = optimisticTask;
this.isProcessing = true;
this.refreshDisplay();
try {
if (this.onStatusChange) {
await this.onStatusChange(previousTask, optimisticTask);
}
} catch (error) {
console.error(
"[TaskStatusIndicator] Failed to change task status",
error,
);
this.task = previousTask;
this.refreshDisplay();
throw error;
} finally {
this.isProcessing = false;
this.refreshDisplay();
}
}
private refreshDisplay(): void {
if (!this.element || !this.statusTextEl) {
return;
}
const context = this.buildStatusContext();
const nextDescription = context.nextStatus
? `, next: ${context.nextStatus}`
: "";
this.statusTextEl.setText(context.displayName);
this.element.setAttribute("data-status", context.statusSlug);
this.element.setAttribute("data-status-mark", context.currentMark);
this.element.setAttribute(
"aria-label",
`Status: ${context.displayName}${nextDescription}`,
);
const disabled =
!context.hasCycle || !this.isInteractionAllowed() || this.isProcessing;
this.element.setAttribute(
"aria-disabled",
String(disabled),
);
this.element.setAttribute(
"aria-busy",
this.isProcessing ? "true" : "false",
);
this.element.setAttribute(
"data-processing",
this.isProcessing ? "true" : "false",
);
this.element.setAttribute(
"data-completed",
context.isCompleted ? "true" : "false",
);
this.element.toggleClass("is-processing", this.isProcessing);
this.element.toggleClass("is-disabled", disabled);
this.element.toggleClass("is-completed", context.isCompleted);
}
private buildStatusContext(): StatusContext {
const config = this.getStatusConfig();
const currentMark = this.getCurrentMark(config);
const statusName = this.findStatusNameForMark(currentMark, config);
const displayName =
statusName ??
this.deriveStatusLabelFromMark(currentMark) ??
(currentMark.trim().length > 0 ? currentMark : "Not Started");
const activeCycle = this.getActiveCycle(config);
const nextStatus = this.getNextStatus(
statusName,
activeCycle,
config,
);
return {
config,
currentMark,
statusName,
displayName,
nextStatus,
statusSlug: this.slugifyStatus(displayName),
isCompleted: this.isCompletedMark(currentMark),
hasCycle: activeCycle.length > 0,
};
}
private getStatusConfig(): TaskStatusConfig {
const settings = this.plugin.settings;
const hasCustomCycle =
Array.isArray(settings.taskStatusCycle) &&
settings.taskStatusCycle.length > 0;
const marks =
settings.taskStatusMarks &&
Object.keys(settings.taskStatusMarks).length > 0
? settings.taskStatusMarks
: FALLBACK_STATUS_MARKS;
return {
cycle: hasCustomCycle
? [...settings.taskStatusCycle]
: Object.keys(marks),
marks: { ...marks },
excludeMarksFromCycle: Array.isArray(settings.excludeMarksFromCycle)
? [...settings.excludeMarksFromCycle]
: [],
};
}
private getCurrentMark(config: TaskStatusConfig): string {
const statusValue = this.task.status ?? "";
const normalizedStatus = this.normalizeStatus(statusValue);
for (const [, mark] of Object.entries(config.marks)) {
if (!mark) continue;
if (mark === statusValue) {
return mark;
}
if (mark.toLowerCase() === statusValue.toLowerCase()) {
return mark;
}
}
for (const [status, mark] of Object.entries(config.marks)) {
if (this.normalizeStatus(status) === normalizedStatus) {
return mark;
}
}
if (statusValue.length === 1) {
return statusValue;
}
return statusValue;
}
private getMarkForStatus(
status: string,
config: TaskStatusConfig,
): string | undefined {
const normalized = this.normalizeStatus(status);
for (const [name, mark] of Object.entries(config.marks)) {
if (this.normalizeStatus(name) === normalized) {
return mark;
}
}
return undefined;
}
private findStatusNameForMark(
mark: string,
config: TaskStatusConfig,
): string | undefined {
const normalizedMark = mark.toLowerCase();
for (const [status, value] of Object.entries(config.marks)) {
if (
value === mark ||
value.toLowerCase() === normalizedMark ||
this.normalizeStatus(status) === this.normalizeStatus(mark)
) {
return status;
}
}
return undefined;
}
private deriveStatusLabelFromMark(mark: string): string | undefined {
if (!mark) {
return undefined;
}
const statusConfig = this.plugin.settings.taskStatuses;
if (!statusConfig) {
return undefined;
}
const normalizedMark = mark.toLowerCase();
for (const [statusKey, rawMarks] of Object.entries(statusConfig)) {
const variants = rawMarks
.split("|")
.map((value) => value.trim())
.filter(Boolean);
if (
variants.some(
(value) =>
value === mark || value.toLowerCase() === normalizedMark,
)
) {
return this.formatStatusKey(statusKey);
}
}
return undefined;
}
private getActiveCycle(config: TaskStatusConfig): string[] {
const excludes = config.excludeMarksFromCycle.map((status) =>
this.normalizeStatus(status),
);
return config.cycle.filter(
(status) => !excludes.includes(this.normalizeStatus(status)),
);
}
private getNextStatus(
currentStatus: string | undefined,
activeCycle: string[],
config: TaskStatusConfig,
): string | undefined {
if (activeCycle.length === 0) {
return undefined;
}
const normalizedCurrent = currentStatus
? this.normalizeStatus(currentStatus)
: undefined;
let currentIndex = -1;
if (normalizedCurrent) {
currentIndex = activeCycle.findIndex(
(status) => this.normalizeStatus(status) === normalizedCurrent,
);
} else {
const mark = this.task.status;
if (mark) {
for (let i = 0; i < activeCycle.length; i++) {
const status = activeCycle[i];
const mapped = this.getMarkForStatus(status, config);
if (
mapped === mark ||
(mapped && mapped.toLowerCase() === mark.toLowerCase())
) {
currentIndex = i;
break;
}
}
}
}
const nextIndex = (currentIndex + 1) % activeCycle.length;
return activeCycle[nextIndex];
}
private normalizeStatus(value: string): string {
return value.trim().toLowerCase();
}
private slugifyStatus(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "unknown";
}
private formatStatusKey(key: string): string {
return key
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[-_]+/g, " ")
.split(" ")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
private isCompletedMark(mark: string): boolean {
if (!mark) {
return false;
}
try {
const lower = mark.toLowerCase();
const completedCfg = String(
this.plugin.settings.taskStatuses?.completed || "x",
);
const completedMarks = completedCfg
.split("|")
.map((value) => value.trim().toLowerCase())
.filter(Boolean);
if (completedMarks.includes(lower)) {
return true;
}
const statusConfig = this.plugin.settings
.taskStatuses as Record<string, string>;
if (statusConfig) {
for (const [statusKey, variants] of Object.entries(
statusConfig,
)) {
const entries = variants
.split("|")
.map((value) => value.trim().toLowerCase())
.filter(Boolean);
if (entries.includes(lower)) {
return statusKey.toLowerCase() === "completed";
}
}
}
} catch (error) {
console.error(
"[TaskStatusIndicator] Failed to evaluate completed mark",
error,
);
}
return false;
}
private buildUpdatedTask(mark: string): Task {
const metadata = { ...this.task.metadata };
const isCompleted = this.isCompletedMark(mark);
if (isCompleted) {
if (!metadata.completedDate) {
metadata.completedDate = Date.now();
}
} else if (metadata.completedDate) {
delete metadata.completedDate;
}
const updatedMarkdown =
typeof this.task.originalMarkdown === "string"
? this.task.originalMarkdown.replace(/\[(.)]/, `[${mark}]`)
: this.task.originalMarkdown;
return {
...this.task,
status: mark,
completed: isCompleted,
originalMarkdown: updatedMarkdown,
metadata,
};
}
private isInteractionAllowed(): boolean {
if (this.canInteract && !this.canInteract()) {
return false;
}
return true;
}
}