Add heading-grouped, nested, draggable todos with keyboard navigation
Some checks failed
Node.js build / build (20.x) (push) Failing after 5m48s
Node.js build / build (22.x) (push) Failing after 5m43s

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,28 @@
import { Menu, setIcon } from 'obsidian';
import { Menu } from 'obsidian';
import type { TodoItem } from '../core/types';
export interface TodoItemCallbacks {
onToggle: () => void;
onMoveClick: () => void;
onToggle: (todo: TodoItem) => void;
onMoveClick: (todo: TodoItem) => void;
onDragStart: (evt: DragEvent, todo: TodoItem) => void;
onDragEnd: (evt: DragEvent) => void;
}
/**
* Creates a DOM element for a todo item with checkbox and menu.
* Collect all line numbers for a todo and its descendants (for drag data).
*/
export function collectChildLineNumbers(todo: TodoItem): number[] {
const lines: number[] = [];
for (const child of todo.children) {
lines.push(child.lineNumber);
lines.push(...collectChildLineNumbers(child));
}
return lines;
}
/**
* Creates a DOM element for a todo item with checkbox, right-click menu,
* drag support, and recursive child rendering.
*/
export function createTodoItemEl(
container: HTMLElement,
@@ -15,12 +30,14 @@ export function createTodoItemEl(
callbacks: TodoItemCallbacks
): HTMLElement {
const itemEl = container.createEl('li', { cls: 'todo-tracker-item' });
itemEl.setAttribute('draggable', 'true');
itemEl.dataset.lineNumber = String(todo.lineNumber);
// Checkbox
const checkboxEl = itemEl.createEl('input', { type: 'checkbox' });
checkboxEl.checked = todo.completed;
checkboxEl.addEventListener('change', () => {
callbacks.onToggle();
callbacks.onToggle(todo);
});
// Text content
@@ -30,12 +47,9 @@ export function createTodoItemEl(
textEl.addClass('todo-tracker-item-completed');
}
// Menu button (ellipsis)
const menuBtn = itemEl.createEl('button', { cls: 'todo-tracker-menu-btn' });
setIcon(menuBtn, 'more-vertical');
menuBtn.setAttribute('aria-label', 'Todo options');
menuBtn.addEventListener('click', (evt) => {
// Right-click context menu
itemEl.addEventListener('contextmenu', (evt) => {
evt.preventDefault();
evt.stopPropagation();
const menu = new Menu();
@@ -44,12 +58,29 @@ export function createTodoItemEl(
.setTitle('Move to...')
.setIcon('arrow-right')
.onClick(() => {
callbacks.onMoveClick();
callbacks.onMoveClick(todo);
});
});
menu.showAtMouseEvent(evt);
});
// Drag events
itemEl.addEventListener('dragstart', (evt) => {
callbacks.onDragStart(evt, todo);
});
itemEl.addEventListener('dragend', (evt) => {
callbacks.onDragEnd(evt);
});
// Render children recursively
if (todo.children.length > 0) {
const nestedList = itemEl.createEl('ul', { cls: 'todo-tracker-nested-list' });
for (const child of todo.children) {
createTodoItemEl(nestedList, child, callbacks);
}
}
return itemEl;
}