feat: add collapsible project note subtasks widget

- Add ProjectNoteDecorations extension for live preview mode
- Create ProjectSubtasksService for shared project logic
- Implement collapsible subtask display with persistent state
- Add comprehensive event listening for real-time updates
- Remove duplicate post-processor implementation
- Use proper Obsidian editorLivePreviewField API
- Add responsive CSS with smooth transitions
This commit is contained in:
Callum Alpass 2025-07-09 21:01:30 +10:00
parent 3a9e2dff25
commit f193dade24
5 changed files with 593 additions and 0 deletions

View file

@ -18,6 +18,7 @@ const CSS_FILES = [
'styles/task-selector-modal.css', // TaskSelectorModal component with proper BEM scoping
'styles/unscheduled-tasks-selector-modal.css', // UnscheduledTasksSelectorModal component with proper BEM scoping
'styles/task-action-palette-modal.css', // TaskActionPaletteModal component with proper BEM scoping
'styles/project-note-subtasks.css', // ProjectNoteSubtasks component with proper BEM scoping
// BEM View Files
'styles/task-list-view.css', // TaskListView component with proper BEM scoping

View file

@ -0,0 +1,373 @@
import { Decoration, DecorationSet, EditorView, PluginSpec, PluginValue, ViewPlugin, ViewUpdate, WidgetType } from '@codemirror/view';
import { Extension, RangeSetBuilder } from '@codemirror/state';
import { TFile, editorLivePreviewField, EventRef } from 'obsidian';
import TaskNotesPlugin from '../main';
import { TaskInfo, EVENT_DATA_CHANGED, EVENT_TASK_UPDATED, EVENT_TASK_DELETED } from '../types';
import { createTaskCard } from '../ui/TaskCard';
import { ProjectSubtasksService } from '../services/ProjectSubtasksService';
class ProjectSubtasksWidget extends WidgetType {
constructor(private plugin: TaskNotesPlugin, private tasks: TaskInfo[]) {
super();
}
toDOM(view: EditorView): HTMLElement {
const container = document.createElement('div');
container.className = 'tasknotes-plugin project-note-subtasks';
// Force block display and full width for inline widget
container.style.display = 'block';
container.style.width = '100%';
container.style.clear = 'both';
container.style.position = 'relative';
// Add title with collapsible functionality
const titleEl = container.createEl('h3', {
text: `Subtasks (${this.tasks.length})`,
cls: 'project-note-subtasks__title'
});
// Create task list container
const taskListContainer = container.createEl('div', {
cls: 'project-note-subtasks__list'
});
// Add collapsible functionality
const isCollapsed = this.getCollapsedState();
if (isCollapsed) {
titleEl.classList.add('collapsed');
taskListContainer.classList.add('collapsed');
}
// Add click handler for collapsing/expanding
titleEl.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const isCurrentlyCollapsed = titleEl.classList.contains('collapsed');
if (isCurrentlyCollapsed) {
titleEl.classList.remove('collapsed');
taskListContainer.classList.remove('collapsed');
this.setCollapsedState(false);
} else {
titleEl.classList.add('collapsed');
taskListContainer.classList.add('collapsed');
this.setCollapsedState(true);
}
});
// Sort and render tasks (use static method to avoid creating new service instance)
const sortedTasks = this.sortTasks(this.tasks);
sortedTasks.forEach(task => {
const taskCard = createTaskCard(task, this.plugin, {
showDueDate: true,
showCheckbox: false,
showArchiveButton: false,
showTimeTracking: false,
showRecurringControls: true,
groupByDate: false
});
taskCard.classList.add('project-note-subtasks__task');
taskListContainer.appendChild(taskCard);
});
return container;
}
private sortTasks(tasks: TaskInfo[]): TaskInfo[] {
return tasks.sort((a, b) => {
// First sort by completion status (incomplete first)
const aCompleted = this.plugin.statusManager.isCompletedStatus(a.status);
const bCompleted = this.plugin.statusManager.isCompletedStatus(b.status);
if (aCompleted !== bCompleted) {
return aCompleted ? 1 : -1;
}
// Then sort by priority
const aPriorityWeight = this.plugin.priorityManager.getPriorityWeight(a.priority);
const bPriorityWeight = this.plugin.priorityManager.getPriorityWeight(b.priority);
if (aPriorityWeight !== bPriorityWeight) {
return bPriorityWeight - aPriorityWeight; // Higher priority first
}
// Then sort by due date (earliest first)
if (a.due && b.due) {
return new Date(a.due).getTime() - new Date(b.due).getTime();
} else if (a.due) {
return -1; // Tasks with due dates come first
} else if (b.due) {
return 1;
}
// Finally sort by title
return a.title.localeCompare(b.title);
});
}
/**
* Get the collapsed state for project subtasks from localStorage
*/
private getCollapsedState(): boolean {
try {
const stored = localStorage.getItem('tasknotes-project-subtasks-collapsed');
return stored === 'true';
} catch (error) {
return false;
}
}
/**
* Set the collapsed state for project subtasks in localStorage
*/
private setCollapsedState(collapsed: boolean): void {
try {
if (collapsed) {
localStorage.setItem('tasknotes-project-subtasks-collapsed', 'true');
} else {
localStorage.removeItem('tasknotes-project-subtasks-collapsed');
}
} catch (error) {
// Ignore localStorage errors
}
}
}
class ProjectNoteDecorationsPlugin implements PluginValue {
decorations: DecorationSet;
private cachedTasks: TaskInfo[] = [];
private currentFile: TFile | null = null;
private projectService: ProjectSubtasksService;
private eventListeners: EventRef[] = [];
private view: EditorView;
constructor(view: EditorView, private plugin: TaskNotesPlugin) {
this.view = view;
this.projectService = new ProjectSubtasksService(plugin);
this.decorations = this.buildDecorations(view);
// Set up event listeners for data changes
this.setupEventListeners();
// Load tasks for current file asynchronously
this.loadTasksForCurrentFile(view);
}
update(update: ViewUpdate) {
// Store the updated view reference
this.view = update.view;
if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
// Check if file changed
const newFile = this.plugin.app.workspace.getActiveFile();
if (newFile !== this.currentFile) {
this.currentFile = newFile;
this.loadTasksForCurrentFile(update.view);
}
}
destroy() {
// Clean up event listeners
this.eventListeners.forEach(listener => {
this.plugin.emitter.offref(listener);
});
this.eventListeners = [];
}
private setupEventListeners() {
// Listen for data changes that might affect project subtasks
const dataChangeListener = this.plugin.emitter.on(EVENT_DATA_CHANGED, () => {
// Refresh tasks for current file when data changes
this.loadTasksForCurrentFile(this.view);
});
const taskUpdateListener = this.plugin.emitter.on(EVENT_TASK_UPDATED, () => {
// Refresh tasks for current file when tasks are updated
this.loadTasksForCurrentFile(this.view);
});
const taskDeleteListener = this.plugin.emitter.on(EVENT_TASK_DELETED, () => {
// Refresh tasks for current file when tasks are deleted
this.loadTasksForCurrentFile(this.view);
});
// Listen for settings changes that might affect project subtasks
const settingsChangeListener = this.plugin.emitter.on('settings-changed', () => {
// Refresh tasks when settings change (e.g., custom fields, statuses)
this.loadTasksForCurrentFile(this.view);
});
// Listen for cache events that might affect project subtasks
const fileUpdateListener = this.plugin.emitter.on('file-updated', (data: { path: string }) => {
// Refresh if the updated file might contain project references
this.loadTasksForCurrentFile(this.view);
});
const fileDeleteListener = this.plugin.emitter.on('file-deleted', (data: { path: string }) => {
// Refresh if a file was deleted that might have affected project references
this.loadTasksForCurrentFile(this.view);
});
const fileRenameListener = this.plugin.emitter.on('file-renamed', (data: { oldPath: string, newPath: string }) => {
// Refresh if a file was renamed that might have affected project references
this.loadTasksForCurrentFile(this.view);
});
this.eventListeners.push(
dataChangeListener,
taskUpdateListener,
taskDeleteListener,
settingsChangeListener,
fileUpdateListener,
fileDeleteListener,
fileRenameListener
);
}
private async loadTasksForCurrentFile(view: EditorView) {
const file = this.plugin.app.workspace.getActiveFile();
if (file instanceof TFile) {
try {
this.cachedTasks = await this.projectService.getTasksLinkedToProject(file);
this.decorations = this.buildDecorations(view);
view.requestMeasure();
} catch (error) {
console.error('Error loading tasks for project note:', error);
}
} else {
this.cachedTasks = [];
}
}
private buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
try {
// Only show in live preview mode, not source mode
if (!view.state.field(editorLivePreviewField)) {
return builder.finish();
}
// Only build decorations if we have cached tasks
if (this.cachedTasks.length === 0) {
return builder.finish();
}
const doc = view.state.doc;
// Ensure document has content
if (doc.length === 0) {
return builder.finish();
}
// Find insertion position after frontmatter/properties
let insertPos = this.findInsertionPosition(view, doc);
// Ensure position is valid
if (insertPos < 0 || insertPos > doc.length) {
insertPos = 0;
}
const widget = Decoration.widget({
widget: new ProjectSubtasksWidget(this.plugin, this.cachedTasks),
side: 1 // Place widget after the position
});
builder.add(insertPos, insertPos, widget);
} catch (error) {
console.error('Error building project note decorations:', error);
}
return builder.finish();
}
private findInsertionPosition(view: EditorView, doc: any): number {
if (doc.lines === 0) return 0;
// Find the end of frontmatter if it exists
let insertionPos = 0;
let inFrontmatter = false;
for (let lineNum = 1; lineNum <= Math.min(doc.lines, 20); lineNum++) {
try {
const line = doc.line(lineNum);
const text = line.text.trim();
// Check for frontmatter start
if (lineNum === 1 && text === '---') {
inFrontmatter = true;
insertionPos = line.to;
continue;
}
// Check for frontmatter end
if (inFrontmatter && text === '---') {
insertionPos = line.to;
inFrontmatter = false;
// Look for next line to insert after frontmatter
if (lineNum < doc.lines) {
const nextLine = doc.line(lineNum + 1);
if (nextLine.text.trim() === '') {
insertionPos = nextLine.to;
}
}
break;
}
// If we're in frontmatter, continue
if (inFrontmatter) {
insertionPos = line.to;
continue;
}
// If no frontmatter, insert at the beginning
if (lineNum === 1) {
insertionPos = 0;
break;
}
} catch (error) {
// If we can't read a line, fall back to previous position
break;
}
}
// If we went through all lines or hit an error, insert at the end of processed content
return Math.max(0, insertionPos);
}
}
const projectNoteDecorationsSpec: PluginSpec<ProjectNoteDecorationsPlugin> = {
decorations: (plugin: ProjectNoteDecorationsPlugin) => plugin.decorations
};
/**
* Create the project note decorations extension
*/
export function createProjectNoteDecorations(plugin: TaskNotesPlugin): Extension {
return ViewPlugin.fromClass(
class extends ProjectNoteDecorationsPlugin {
constructor(view: EditorView) {
super(view, plugin);
}
destroy() {
super.destroy();
}
},
projectNoteDecorationsSpec
);
}

View file

@ -54,6 +54,7 @@ import { FilterService } from './services/FilterService';
import { ViewStateManager } from './services/ViewStateManager';
import { createTaskLinkOverlay, dispatchTaskUpdate } from './editor/TaskLinkOverlay';
import { createReadingModeTaskLinkProcessor } from './editor/ReadingModeTaskLinkProcessor';
import { createProjectNoteDecorations } from './editor/ProjectNoteDecorations';
import { DragDropManager } from './utils/DragDropManager';
import { ICSSubscriptionService } from './services/ICSSubscriptionService';
import { MigrationService } from './services/MigrationService';
@ -265,6 +266,9 @@ export default class TaskNotesPlugin extends Plugin {
// Register essential editor extensions (now safe after layout ready)
this.registerEditorExtension(createTaskLinkOverlay(this));
// Register project note decorations for live preview
this.registerEditorExtension(createProjectNoteDecorations(this));
// Register reading mode task link processor
this.registerMarkdownPostProcessor(createReadingModeTaskLinkProcessor(this));

View file

@ -0,0 +1,87 @@
import { TFile } from 'obsidian';
import TaskNotesPlugin from '../main';
import { TaskInfo } from '../types';
export class ProjectSubtasksService {
private plugin: TaskNotesPlugin;
constructor(plugin: TaskNotesPlugin) {
this.plugin = plugin;
}
/**
* Get all tasks that reference this file as a project
*/
async getTasksLinkedToProject(projectFile: TFile): Promise<TaskInfo[]> {
try {
const allTasks = await this.plugin.cacheManager.getAllTasks();
const projectFileName = projectFile.basename;
const projectPath = projectFile.path;
return allTasks.filter(task => {
if (!task.projects || task.projects.length === 0) return false;
return task.projects.some(project => {
if (!project || typeof project !== 'string' || project.trim() === '') return false;
// Check for wikilink format [[Note Name]]
if (project.startsWith('[[') && project.endsWith(']]')) {
const linkedNoteName = project.slice(2, -2).trim();
if (!linkedNoteName) return false;
// Try to resolve the link using Obsidian's metadata cache
const resolvedFile = this.plugin.app.metadataCache.getFirstLinkpathDest(linkedNoteName, '');
if (resolvedFile && resolvedFile.path === projectFile.path) {
return true;
}
// Fallback to string matching
return linkedNoteName === projectFileName || linkedNoteName === projectPath;
}
// Check for plain text match
const trimmedProject = String(project).trim();
return trimmedProject === projectFileName || trimmedProject === projectPath;
});
});
} catch (error) {
console.error('Error getting tasks linked to project:', error);
return [];
}
}
/**
* Sort tasks by priority and status
*/
sortTasks(tasks: TaskInfo[]): TaskInfo[] {
return tasks.sort((a, b) => {
// First sort by completion status (incomplete first)
const aCompleted = this.plugin.statusManager.isCompletedStatus(a.status);
const bCompleted = this.plugin.statusManager.isCompletedStatus(b.status);
if (aCompleted !== bCompleted) {
return aCompleted ? 1 : -1;
}
// Then sort by priority
const aPriorityWeight = this.plugin.priorityManager.getPriorityWeight(a.priority);
const bPriorityWeight = this.plugin.priorityManager.getPriorityWeight(b.priority);
if (aPriorityWeight !== bPriorityWeight) {
return bPriorityWeight - aPriorityWeight; // Higher priority first
}
// Then sort by due date (earliest first)
if (a.due && b.due) {
return new Date(a.due).getTime() - new Date(b.due).getTime();
} else if (a.due) {
return -1; // Tasks with due dates come first
} else if (b.due) {
return 1;
}
// Finally sort by title
return a.title.localeCompare(b.title);
});
}
}

View file

@ -0,0 +1,128 @@
/*
* Project Note Subtasks Component
*
* Styles for the subtask display area that appears in project notes
* via CodeMirror decorations in live preview mode. Uses BEM methodology.
*/
.tasknotes-plugin .project-note-subtasks {
display: block !important;
width: 100% !important;
margin: 1.5em 0;
padding: 1em;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
background-color: var(--background-secondary);
clear: both;
position: relative;
z-index: 1;
}
.tasknotes-plugin .project-note-subtasks__title {
display: flex;
align-items: center;
font-size: 1.1em;
font-weight: 600;
margin: 0 0 0.8em 0;
color: var(--text-normal);
cursor: pointer;
user-select: none;
transition: color 0.2s ease;
}
.tasknotes-plugin .project-note-subtasks__title:hover {
color: var(--text-accent);
}
.tasknotes-plugin .project-note-subtasks__title::before {
content: "▼";
margin-right: 0.5em;
font-size: 0.8em;
transition: transform 0.2s ease;
color: var(--text-muted);
}
.tasknotes-plugin .project-note-subtasks__title.collapsed::before {
transform: rotate(-90deg);
}
.tasknotes-plugin .project-note-subtasks__list {
display: flex;
flex-direction: column;
gap: 0.5em;
overflow: hidden;
transition: max-height 0.3s ease, opacity 0.3s ease;
max-height: 1000px;
opacity: 1;
}
.tasknotes-plugin .project-note-subtasks__list.collapsed {
max-height: 0;
opacity: 0;
margin: 0;
padding: 0;
}
.tasknotes-plugin .project-note-subtasks__task {
/* Task card wrapper */
margin-bottom: 0.3em;
}
.tasknotes-plugin .project-note-subtasks__task .task-card {
/* Enhance task card for subtask display */
background-color: var(--background-primary);
border-left: 3px solid var(--accent-color);
padding: 0.6em 0.8em;
border-radius: var(--radius-s);
transition: all 0.2s ease;
}
.tasknotes-plugin .project-note-subtasks__task .task-card:hover {
transform: translateX(2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.tasknotes-plugin .project-note-subtasks__task .task-card--completed {
opacity: 0.7;
border-left-color: var(--text-muted);
}
.tasknotes-plugin .project-note-subtasks__task .task-card--completed:hover {
opacity: 0.9;
}
/* Responsive design */
@media (max-width: 768px) {
.tasknotes-plugin .project-note-subtasks {
margin: 1em 0;
padding: 0.8em;
}
.tasknotes-plugin .project-note-subtasks__title {
font-size: 1em;
margin-bottom: 0.6em;
}
.tasknotes-plugin .project-note-subtasks__list {
gap: 0.3em;
}
.tasknotes-plugin .project-note-subtasks__task .task-card {
padding: 0.5em 0.6em;
}
}
/* Dark mode adjustments */
.theme-dark .tasknotes-plugin .project-note-subtasks {
background-color: var(--background-secondary);
border-color: var(--background-modifier-border);
}
.theme-dark .tasknotes-plugin .project-note-subtasks__task .task-card {
background-color: var(--background-primary);
border-left-color: var(--accent-color);
}
.theme-dark .tasknotes-plugin .project-note-subtasks__task .task-card--completed {
border-left-color: var(--text-muted);
}