Add heading-grouped, nested, draggable todos with keyboard navigation

Enhance the todo tracker sidebar panel with:
- Group todos by heading from the source file
- Display nested (indented) todos as a tree
- Drag-and-drop to reorder todos within/across heading groups
- Keyboard navigation (arrow keys, Enter to open, M to move, toggle hotkey)
- Right-click context menu replacing the ellipsis button
- Proper focus management (no focus stealing on refresh)
- Platform-aware Obsidian hotkey matching for toggle checkbox

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 09:56:45 -08:00
parent 2936f7d359
commit 349810aecf
8 changed files with 984 additions and 53 deletions

View File

@@ -1,13 +1,24 @@
import { ItemView, TFile, WorkspaceLeaf } from 'obsidian';
import type { TodoItem } from '../core/types';
import { parseTodos } from '../core/todo-parser';
import { toggleTodo } from '../core/todo-transformer';
import { createTodoItemEl } from './todo-item-component';
import { ItemView, Keymap, Platform, TFile, WorkspaceLeaf } from 'obsidian';
import type { TodoItem, TodoGroup } from '../core/types';
import { parseTodosGroupedByHeading } from '../core/todo-parser';
import { toggleTodo, moveTodoWithChildren, moveTodoUnderHeadingInFile } from '../core/todo-transformer';
import { createTodoItemEl, collectChildLineNumbers } from './todo-item-component';
export const TODO_VIEW_TYPE = 'todo-tracker-view';
interface FlatTodoEntry {
todo: TodoItem;
element: HTMLElement;
group: TodoGroup;
}
export class TodoSidebarView extends ItemView {
private currentFile: TFile | null = null;
private flatTodoList: FlatTodoEntry[] = [];
private focusedIndex = -1;
private groups: TodoGroup[] = [];
private draggedTodo: TodoItem | null = null;
private draggedChildLines: number[] = [];
constructor(leaf: WorkspaceLeaf) {
super(leaf);
@@ -26,16 +37,19 @@ export class TodoSidebarView extends ItemView {
}
async onOpen(): Promise<void> {
// Register keyboard navigation once (Bug 4 fix: no duplicate listeners)
this.contentEl.addEventListener('keydown', (evt) => {
this.handleKeydown(evt);
});
await this.refresh();
// Listen for active leaf changes
this.registerEvent(
this.app.workspace.on('active-leaf-change', () => {
this.refresh();
})
);
// Listen for file modifications
this.registerEvent(
this.app.vault.on('modify', (file) => {
if (this.currentFile && file.path === this.currentFile.path) {
@@ -52,7 +66,6 @@ export class TodoSidebarView extends ItemView {
async refresh(): Promise<void> {
const activeFile = this.app.workspace.getActiveFile();
// Check if it's a markdown file
if (!activeFile || activeFile.extension !== 'md') {
this.currentFile = null;
this.renderEmpty('Open a markdown file to see its todos');
@@ -62,46 +75,420 @@ export class TodoSidebarView extends ItemView {
this.currentFile = activeFile;
const content = await this.app.vault.read(activeFile);
const { todos } = parseTodos(content);
this.groups = parseTodosGroupedByHeading(content);
if (todos.length === 0) {
// Check if there are any todos at all
const hasTodos = this.groups.some((g) => g.todos.length > 0);
if (!hasTodos) {
this.renderEmpty('No todos in this note');
return;
}
this.renderTodos(todos, activeFile);
this.renderGroups(activeFile);
}
private renderEmpty(message: string): void {
const container = this.contentEl;
container.empty();
container.addClass('todo-tracker-container');
this.flatTodoList = [];
this.focusedIndex = -1;
const emptyEl = container.createDiv({ cls: 'todo-tracker-empty' });
emptyEl.setText(message);
}
private renderTodos(todos: TodoItem[], file: TFile): void {
private renderGroups(file: TFile): void {
const container = this.contentEl;
container.empty();
container.addClass('todo-tracker-container');
container.setAttribute('tabindex', '0');
// Header with file name
const headerEl = container.createDiv({ cls: 'todo-tracker-header' });
headerEl.setText(file.basename);
// Todo list
const listEl = container.createEl('ul', { cls: 'todo-tracker-list' });
// Build flat todo list for keyboard navigation
this.flatTodoList = [];
for (const todo of todos) {
const itemEl = createTodoItemEl(listEl, todo, {
onToggle: () => this.handleToggle(file, todo),
onMoveClick: () => this.handleMoveClick(todo, file),
});
listEl.appendChild(itemEl);
for (const group of this.groups) {
const groupEl = container.createDiv({ cls: 'todo-tracker-group' });
// Heading label
if (group.heading) {
const headingEl = groupEl.createDiv({ cls: 'todo-tracker-group-heading' });
headingEl.setText(group.heading.text);
headingEl.dataset.headingLine = String(group.heading.lineNumber);
}
// Todo list for this group
const listEl = groupEl.createEl('ul', { cls: 'todo-tracker-list' });
for (const todo of group.todos) {
this.renderTodoItem(listEl, todo, file, group);
}
// Set up drop zone on the group
this.setupGroupDropZone(groupEl, group, file);
}
// Set initial focus index (Bug 1 fix: don't call container.focus())
if (this.flatTodoList.length > 0 && this.focusedIndex === -1) {
this.focusedIndex = 0;
}
// Clamp focus index if list shrank
if (this.focusedIndex >= this.flatTodoList.length) {
this.focusedIndex = Math.max(0, this.flatTodoList.length - 1);
}
this.updateFocusVisual();
}
private renderTodoItem(
container: HTMLElement,
todo: TodoItem,
file: TFile,
group: TodoGroup
): void {
const itemEl = createTodoItemEl(container, todo, {
onToggle: (t) => this.handleToggle(file, t),
onMoveClick: (t) => this.handleMoveClick(t, file),
onDragStart: (evt, t) => this.handleDragStart(evt, t),
onDragEnd: (evt) => this.handleDragEnd(evt),
});
// Register in flat list for keyboard navigation
this.flatTodoList.push({ todo, element: itemEl, group });
// Set up drop target on individual items
this.setupItemDropZone(itemEl, todo, file);
// Recursively register children in flat list
for (const child of todo.children) {
this.registerChildInFlatList(itemEl, child, group);
}
}
private registerChildInFlatList(
parentEl: HTMLElement,
todo: TodoItem,
group: TodoGroup
): void {
// Find the child's <li> element within the parent
const childEl = parentEl.querySelector(
`li[data-line-number="${todo.lineNumber}"]`
) as HTMLElement | null;
if (childEl) {
this.flatTodoList.push({ todo, element: childEl, group });
this.setupItemDropZone(childEl, todo, this.currentFile!);
for (const grandchild of todo.children) {
this.registerChildInFlatList(childEl, grandchild, group);
}
}
}
// --- Drag and Drop ---
private handleDragStart(evt: DragEvent, todo: TodoItem): void {
this.draggedTodo = todo;
this.draggedChildLines = collectChildLineNumbers(todo);
evt.dataTransfer?.setData('text/plain', todo.text);
if (evt.dataTransfer) {
evt.dataTransfer.effectAllowed = 'move';
}
// Add dragging class
const target = evt.target as HTMLElement;
target.addClass('todo-tracker-item-dragging');
}
private handleDragEnd(evt: DragEvent): void {
this.draggedTodo = null;
this.draggedChildLines = [];
// Remove dragging class
const target = evt.target as HTMLElement;
target.removeClass('todo-tracker-item-dragging');
// Clean up all drag-over indicators
this.contentEl.querySelectorAll('.todo-tracker-drag-over').forEach((el) => {
el.classList.remove('todo-tracker-drag-over');
});
this.contentEl.querySelectorAll('.todo-tracker-drop-above, .todo-tracker-drop-below').forEach((el) => {
el.classList.remove('todo-tracker-drop-above');
el.classList.remove('todo-tracker-drop-below');
});
}
private setupItemDropZone(itemEl: HTMLElement, targetTodo: TodoItem, file: TFile): void {
itemEl.addEventListener('dragover', (evt) => {
if (!this.draggedTodo || this.draggedTodo.lineNumber === targetTodo.lineNumber) return;
evt.preventDefault();
if (evt.dataTransfer) {
evt.dataTransfer.dropEffect = 'move';
}
// Determine drop position (above or below)
const rect = itemEl.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const isAbove = evt.clientY < midY;
itemEl.removeClass('todo-tracker-drop-above');
itemEl.removeClass('todo-tracker-drop-below');
itemEl.addClass(isAbove ? 'todo-tracker-drop-above' : 'todo-tracker-drop-below');
});
itemEl.addEventListener('dragleave', () => {
itemEl.removeClass('todo-tracker-drop-above');
itemEl.removeClass('todo-tracker-drop-below');
});
itemEl.addEventListener('drop', (evt) => {
evt.preventDefault();
itemEl.removeClass('todo-tracker-drop-above');
itemEl.removeClass('todo-tracker-drop-below');
if (!this.draggedTodo || !file) return;
const rect = itemEl.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const isAbove = evt.clientY < midY;
// Calculate target line: insert before or after the target todo
const targetLine = isAbove ? targetTodo.lineNumber : targetTodo.lineNumber + 1;
this.performMove(file, targetLine);
});
}
private setupGroupDropZone(groupEl: HTMLElement, group: TodoGroup, file: TFile): void {
groupEl.addEventListener('dragover', (evt) => {
if (!this.draggedTodo) return;
// Only handle if drop is on the group itself, not a child item
const target = evt.target as HTMLElement;
if (target.closest('.todo-tracker-item')) return;
evt.preventDefault();
if (evt.dataTransfer) {
evt.dataTransfer.dropEffect = 'move';
}
groupEl.addClass('todo-tracker-drag-over');
});
groupEl.addEventListener('dragleave', (evt) => {
// Only remove if actually leaving the group
const relatedTarget = evt.relatedTarget as HTMLElement | null;
if (!relatedTarget || !groupEl.contains(relatedTarget)) {
groupEl.removeClass('todo-tracker-drag-over');
}
});
groupEl.addEventListener('drop', (evt) => {
evt.preventDefault();
groupEl.removeClass('todo-tracker-drag-over');
if (!this.draggedTodo || !file || !group.heading) return;
// Find the next heading's line number for section boundary
const groupIndex = this.groups.indexOf(group);
let nextHeadingLine: number | undefined;
for (let i = groupIndex + 1; i < this.groups.length; i++) {
if (this.groups[i]?.heading) {
nextHeadingLine = this.groups[i]!.heading!.lineNumber;
break;
}
}
this.performMoveUnderHeading(file, group.heading.lineNumber, nextHeadingLine);
});
}
private async performMove(file: TFile, targetLine: number): Promise<void> {
if (!this.draggedTodo) return;
const todoLine = this.draggedTodo.lineNumber;
const childLines = this.draggedChildLines;
await this.app.vault.process(file, (content) => {
return moveTodoWithChildren(content, todoLine, childLines, targetLine);
});
this.draggedTodo = null;
this.draggedChildLines = [];
}
private async performMoveUnderHeading(
file: TFile,
headingLine: number,
nextHeadingLine?: number
): Promise<void> {
if (!this.draggedTodo) return;
const todoLine = this.draggedTodo.lineNumber;
const childLines = this.draggedChildLines;
await this.app.vault.process(file, (content) => {
return moveTodoUnderHeadingInFile(content, todoLine, childLines, headingLine, nextHeadingLine);
});
this.draggedTodo = null;
this.draggedChildLines = [];
}
// --- Keyboard Navigation ---
private handleKeydown(evt: KeyboardEvent): void {
if (this.flatTodoList.length === 0 || !this.currentFile) return;
switch (evt.key) {
case 'ArrowDown':
evt.preventDefault();
this.moveFocus(1);
break;
case 'ArrowUp':
evt.preventDefault();
this.moveFocus(-1);
break;
case 'Enter':
evt.preventDefault();
this.openFocusedTodoInEditor(this.currentFile);
break;
case 'm':
case 'M':
// Only trigger move if no modifier keys are pressed
if (!evt.ctrlKey && !evt.metaKey && !evt.altKey && !evt.shiftKey) {
evt.preventDefault();
this.moveFocusedTodo(this.currentFile);
}
break;
default:
// Check if this matches the toggle checkbox hotkey
if (this.isToggleCheckboxHotkey(evt)) {
evt.preventDefault();
this.toggleFocusedTodo(this.currentFile);
}
break;
}
}
private isToggleCheckboxHotkey(evt: KeyboardEvent): boolean {
// Bug 3 fix: properly match Obsidian hotkeys using correct API
const hotkeyManager = (this.app as any).hotkeyManager;
if (!hotkeyManager) return false;
const commandId = 'editor:toggle-checklist-status';
// Get custom hotkeys (user-configured), then fall back to defaults
const customHotkeys = hotkeyManager.getHotkeys?.(commandId) ?? [];
const defaultHotkeys = hotkeyManager.getDefaultHotkeys?.(commandId) ?? [];
const allHotkeys = [...customHotkeys, ...defaultHotkeys];
if (allHotkeys.length === 0) return false;
for (const hotkey of allHotkeys) {
if (this.matchesHotkey(evt, hotkey)) {
return true;
}
}
return false;
}
private matchesHotkey(evt: KeyboardEvent, hotkey: { modifiers: string[]; key: string }): boolean {
const modifiers = hotkey.modifiers ?? [];
const key = hotkey.key;
// Check each modifier
const needsMod = modifiers.includes('Mod');
const needsCtrl = modifiers.includes('Ctrl');
const needsShift = modifiers.includes('Shift');
const needsAlt = modifiers.includes('Alt');
// "Mod" means Cmd on Mac, Ctrl on Windows/Linux
const isMac = Platform.isMacOS;
const modPressed = isMac ? evt.metaKey : evt.ctrlKey;
const modExpected = needsMod;
// On Mac, Ctrl is separate from Mod (Cmd)
// On Windows/Linux, Mod and Ctrl both map to ctrlKey
let ctrlMatch: boolean;
if (isMac) {
ctrlMatch = evt.ctrlKey === needsCtrl;
} else {
// On non-Mac, Ctrl key satisfies both 'Mod' and 'Ctrl'
ctrlMatch = evt.ctrlKey === (needsMod || needsCtrl);
}
const metaMatch = isMac
? evt.metaKey === needsMod
: evt.metaKey === false; // Meta shouldn't be pressed on non-Mac unless specified
const shiftMatch = evt.shiftKey === needsShift;
const altMatch = evt.altKey === needsAlt;
const keyMatch = evt.key.toLowerCase() === key?.toLowerCase();
if (isMac) {
return metaMatch && ctrlMatch && shiftMatch && altMatch && keyMatch;
} else {
return ctrlMatch && shiftMatch && altMatch && !evt.metaKey && keyMatch;
}
}
private moveFocus(delta: number): void {
const newIndex = Math.max(0, Math.min(this.flatTodoList.length - 1, this.focusedIndex + delta));
if (newIndex !== this.focusedIndex) {
this.focusedIndex = newIndex;
this.updateFocusVisual();
}
}
private updateFocusVisual(): void {
// Remove focus from all items
for (const entry of this.flatTodoList) {
entry.element.removeClass('todo-tracker-item-focused');
}
// Add focus to current
const current = this.flatTodoList[this.focusedIndex];
if (current) {
current.element.addClass('todo-tracker-item-focused');
current.element.scrollIntoView({ block: 'nearest' });
}
}
private openFocusedTodoInEditor(file: TFile): void {
const entry = this.flatTodoList[this.focusedIndex];
if (!entry) return;
// Open the file and scroll to the line
const leaf = this.app.workspace.getLeaf(false);
leaf.openFile(file).then(() => {
const editor = this.app.workspace.activeEditor?.editor;
if (editor) {
const line = entry.todo.lineNumber;
editor.setCursor({ line, ch: 0 });
editor.scrollIntoView({ from: { line, ch: 0 }, to: { line, ch: 0 } }, true);
}
});
}
private toggleFocusedTodo(file: TFile): void {
const entry = this.flatTodoList[this.focusedIndex];
if (!entry) return;
this.handleToggle(file, entry.todo);
}
private moveFocusedTodo(file: TFile): void {
const entry = this.flatTodoList[this.focusedIndex];
if (!entry) return;
this.handleMoveClick(entry.todo, file);
}
// --- Event Handlers ---
private async handleToggle(file: TFile, todo: TodoItem): Promise<void> {
await this.app.vault.process(file, (content) => {
return toggleTodo(content, todo.lineNumber);
@@ -109,7 +496,6 @@ export class TodoSidebarView extends ItemView {
}
private handleMoveClick(todo: TodoItem, file: TFile): void {
// Import dynamically to avoid circular dependencies
import('../modals/note-select-modal').then(({ NoteSelectModal }) => {
new NoteSelectModal(this.app, todo, file).open();
});