Add focus activation, 3-zone drag nesting, and click-to-navigate
Some checks failed
Node.js build / build (22.x) (push) Has been cancelled
Node.js build / build (20.x) (push) Has been cancelled

Implement remaining Round 3 enhancements:
- ArrowDown when panel unfocused activates it at first item (like Outline view)
- 3-zone drag-drop: top/bottom thirds insert above/below, middle third nests as child
- Click on todo text to focus it in editor (onClick callback)
- Dragging parent automatically moves nested children (stopPropagation fix)
- Cross-file move inserts todo below heading with blank line (addBlankLine param)
- Updated CLAUDE.md with sidebar view architecture documentation

Build: 85 tests pass, production build succeeds.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 10:17:27 -08:00
parent 349810aecf
commit a00b96231c
8 changed files with 263 additions and 31 deletions

View File

@@ -95,3 +95,49 @@ From AGENTS.md and Obsidian best practices:
- Keep startup lightweight; defer heavy work until needed - Keep startup lightweight; defer heavy work until needed
- Use stable command IDs (don't rename after release) - Use stable command IDs (don't rename after release)
- Provide sensible defaults for all settings - Provide sensible defaults for all settings
## Sidebar View Architecture
### Key Files
- `src/views/todo-sidebar-view.ts` — Main sidebar panel (`TodoSidebarView` extends `ItemView`)
- `src/views/todo-item-component.ts` — Pure DOM factory for individual todo `<li>` elements
- `src/core/todo-parser.ts` — Parses markdown into `TodoGroup[]` with nested `TodoItem` trees
- `src/core/todo-transformer.ts` — Pure functions for modifying file content (toggle, move, indent)
- `src/modals/note-select-modal.ts` — First step of cross-file move: pick target note
- `src/modals/heading-select-modal.ts` — Second step: pick heading within target note
### Data Flow
1. `parseTodosGroupedByHeading(content)` → `TodoGroup[]` (each group has a heading + tree of todos)
2. `buildTodoTree(flatTodos)` — stack-based algorithm: todos whose indent > parent's become children
3. `renderGroups()` renders each group; `createTodoItemEl()` renders items recursively
4. `flatTodoList: FlatTodoEntry[]` is rebuilt each render for keyboard navigation
### Event Listener Lifecycle (Critical)
- **Register keydown listener ONCE in `onOpen()`**, not in `renderGroups()` — otherwise listeners accumulate on every file change and arrow keys skip items
- Use `this.currentFile` inside handlers instead of closure variables (file changes between renders)
- `registerEvent()` wrappers handle cleanup automatically on view close
### Keyboard Navigation
- `focusedIndex = -1` means panel is not yet activated (no visual focus)
- First ArrowDown/ArrowUp when `focusedIndex === -1` sets it to 0 (activates focus like Outline view)
- `flatTodoList` flattens the entire tree depth-first; arrow keys walk this list linearly
- Container has `tabindex="0"` so it can receive keyboard events without stealing focus on render
### Drag and Drop
- Parent `<li>` and all nested `<li>` are `draggable="true"`; `stopPropagation` on `dragstart` prevents child drag from bubbling to parent
- `handleDragStart` stores `draggedTodo` and `draggedChildLines` (via `collectChildLineNumbers`)
- Item drop zones use 3 zones (top/middle/bottom thirds):
- Top → insert above
- Middle → nest as child (increase indent, insert after target's last descendant)
- Bottom → insert below
- Group (heading) drop zones allow dropping onto the heading area to move to end of that section
- `performNest` adjusts indentation with `indentTodoLines(lines, delta)` before inserting
### Hotkey Matching (Platform-Aware)
- Uses `app.hotkeyManager.getHotkeys(commandId)` and `getDefaultHotkeys(commandId)` (internal API)
- `Platform.isMacOS` distinguishes Mod (Cmd on Mac, Ctrl on Windows) from Ctrl
- See `matchesHotkey()` in `todo-sidebar-view.ts` for the full matching logic
### Cross-File Move
- Always pass `addBlankLine: true` to `insertTodoUnderHeading` from modals — inserts a blank line between the heading and the moved todo for readability
- In-file drag moves use `moveTodoWithChildren` directly (no blank line needed)

View File

@@ -6,6 +6,7 @@ import {
insertTodoUnderHeading, insertTodoUnderHeading,
moveTodoWithChildren, moveTodoWithChildren,
moveTodoUnderHeadingInFile, moveTodoUnderHeadingInFile,
indentTodoLines,
} from './todo-transformer'; } from './todo-transformer';
describe('toggleTodo', () => { describe('toggleTodo', () => {
@@ -256,3 +257,61 @@ describe('moveTodoUnderHeadingInFile', () => {
expect(result).toBe('## H1\n## H2\n- [ ] A\n## H3'); expect(result).toBe('## H1\n## H2\n- [ ] A\n## H3');
}); });
}); });
describe('indentTodoLines', () => {
it('should indent a single line by one level', () => {
const result = indentTodoLines(['- [ ] Hello'], 1);
expect(result).toEqual([' - [ ] Hello']);
});
it('should indent multiple lines', () => {
const result = indentTodoLines(['- [ ] Parent', ' - [ ] Child'], 1);
expect(result).toEqual([' - [ ] Parent', ' - [ ] Child']);
});
it('should outdent by one level', () => {
const result = indentTodoLines([' - [ ] Hello'], -1);
expect(result).toEqual(['- [ ] Hello']);
});
it('should not outdent past zero indent', () => {
const result = indentTodoLines(['- [ ] Hello'], -1);
expect(result).toEqual(['- [ ] Hello']);
});
it('should handle zero delta (no-op)', () => {
const result = indentTodoLines([' - [ ] Hello'], 0);
expect(result).toEqual([' - [ ] Hello']);
});
it('should indent by multiple levels', () => {
const result = indentTodoLines(['- [ ] Hello'], 2);
expect(result).toEqual([' - [ ] Hello']);
});
});
describe('insertTodoUnderHeading with blank line', () => {
it('should insert with blank line after heading when no next heading', () => {
const content = '# Heading\nExisting content';
const result = insertTodoUnderHeading(content, 0, '- [ ] New todo', undefined, true);
expect(result).toBe('# Heading\n\n- [ ] New todo\nExisting content');
});
it('should insert with blank line after empty heading', () => {
const content = '# Only heading';
const result = insertTodoUnderHeading(content, 0, '- [ ] New todo', undefined, true);
expect(result).toBe('# Only heading\n\n- [ ] New todo');
});
it('should insert with blank line before next heading', () => {
const content = '# H1\n# H2';
const result = insertTodoUnderHeading(content, 0, '- [ ] New todo', 1, true);
expect(result).toBe('# H1\n\n- [ ] New todo\n# H2');
});
it('should not double blank line if one already exists after heading', () => {
const content = '# Heading\n\nExisting content';
const result = insertTodoUnderHeading(content, 0, '- [ ] New todo', undefined, true);
expect(result).toBe('# Heading\n\n- [ ] New todo\nExisting content');
});
});

View File

@@ -137,18 +137,47 @@ export function moveTodoUnderHeadingInFile(
return moveTodoWithChildren(content, todoLineNumber, childLineNumbers, targetLineNumber); return moveTodoWithChildren(content, todoLineNumber, childLineNumbers, targetLineNumber);
} }
/**
* Adjust indentation of todo lines by a delta (positive = indent, negative = outdent).
* Each level is 2 spaces. Will not outdent past zero.
*/
export function indentTodoLines(todoLines: string[], indentDelta: number): string[] {
if (indentDelta === 0) return [...todoLines];
const indent = ' '; // 2 spaces per level
return todoLines.map((line) => {
if (indentDelta > 0) {
return indent.repeat(indentDelta) + line;
} else {
// Outdent: remove leading spaces
let result = line;
for (let i = 0; i < Math.abs(indentDelta); i++) {
if (result.startsWith(indent)) {
result = result.slice(indent.length);
} else if (result.startsWith('\t')) {
result = result.slice(1);
}
}
return result;
}
});
}
/** /**
* Insert a todo line under a specific heading. * Insert a todo line under a specific heading.
* @param content - The file content * @param content - The file content
* @param headingLineNumber - The line number of the heading (0-based) * @param headingLineNumber - The line number of the heading (0-based)
* @param todoLine - The todo line to insert * @param todoLine - The todo line to insert
* @param nextHeadingLineNumber - Optional line number of the next heading (for section boundary) * @param nextHeadingLineNumber - Optional line number of the next heading (for section boundary)
* @param addBlankLine - Whether to add a blank line between the heading and the todo
*/ */
export function insertTodoUnderHeading( export function insertTodoUnderHeading(
content: string, content: string,
headingLineNumber: number, headingLineNumber: number,
todoLine: string, todoLine: string,
nextHeadingLineNumber?: number nextHeadingLineNumber?: number,
addBlankLine = false
): string { ): string {
const lines = content.split('\n'); const lines = content.split('\n');
@@ -156,15 +185,26 @@ export function insertTodoUnderHeading(
let insertPosition: number; let insertPosition: number;
if (nextHeadingLineNumber !== undefined) { if (nextHeadingLineNumber !== undefined) {
// Insert just before the next heading (at the end of this section)
insertPosition = nextHeadingLineNumber; insertPosition = nextHeadingLineNumber;
} else { } else {
// No next heading - insert right after the heading line
insertPosition = headingLineNumber + 1; insertPosition = headingLineNumber + 1;
} }
// Insert the todo line if (addBlankLine) {
lines.splice(insertPosition, 0, todoLine); // Check if there's already a blank line at the insert position
const lineAfterHeading = lines[headingLineNumber + 1];
const alreadyHasBlank = lineAfterHeading !== undefined && lineAfterHeading.trim() === '';
if (alreadyHasBlank) {
// Insert after the existing blank line
lines.splice(headingLineNumber + 2, 0, todoLine);
} else {
// Insert blank line + todo
lines.splice(insertPosition, 0, '', todoLine);
}
} else {
lines.splice(insertPosition, 0, todoLine);
}
return lines.join('\n'); return lines.join('\n');
} }

View File

@@ -79,7 +79,8 @@ export class HeadingSelectModal extends FuzzySuggestModal<HeadingOption> {
content, content,
selectedHeading.position.start.line, selectedHeading.position.start.line,
todoLine, todoLine,
nextHeadingLine nextHeadingLine,
true
); );
}); });
} else { } else {

View File

@@ -54,7 +54,7 @@ export class NoteSelectModal extends FuzzySuggestModal<TFile> {
if (headingLineNumber !== undefined) { if (headingLineNumber !== undefined) {
const { insertTodoUnderHeading } = await import('../core/todo-transformer'); const { insertTodoUnderHeading } = await import('../core/todo-transformer');
await this.app.vault.process(targetFile, (content) => { await this.app.vault.process(targetFile, (content) => {
return insertTodoUnderHeading(content, headingLineNumber, todoLine, nextHeadingLineNumber); return insertTodoUnderHeading(content, headingLineNumber, todoLine, nextHeadingLineNumber, true);
}); });
} else { } else {
await this.app.vault.process(targetFile, (content) => { await this.app.vault.process(targetFile, (content) => {

View File

@@ -4,6 +4,7 @@ import type { TodoItem } from '../core/types';
export interface TodoItemCallbacks { export interface TodoItemCallbacks {
onToggle: (todo: TodoItem) => void; onToggle: (todo: TodoItem) => void;
onMoveClick: (todo: TodoItem) => void; onMoveClick: (todo: TodoItem) => void;
onClick: (todo: TodoItem) => void;
onDragStart: (evt: DragEvent, todo: TodoItem) => void; onDragStart: (evt: DragEvent, todo: TodoItem) => void;
onDragEnd: (evt: DragEvent) => void; onDragEnd: (evt: DragEvent) => void;
} }
@@ -40,12 +41,16 @@ export function createTodoItemEl(
callbacks.onToggle(todo); callbacks.onToggle(todo);
}); });
// Text content // Text content — click to navigate to editor
const textEl = itemEl.createEl('span', { cls: 'todo-tracker-item-text' }); const textEl = itemEl.createEl('span', { cls: 'todo-tracker-item-text' });
textEl.setText(todo.text); textEl.setText(todo.text);
if (todo.completed) { if (todo.completed) {
textEl.addClass('todo-tracker-item-completed'); textEl.addClass('todo-tracker-item-completed');
} }
textEl.addEventListener('click', (evt) => {
evt.stopPropagation();
callbacks.onClick(todo);
});
// Right-click context menu // Right-click context menu
itemEl.addEventListener('contextmenu', (evt) => { itemEl.addEventListener('contextmenu', (evt) => {
@@ -65,12 +70,14 @@ export function createTodoItemEl(
menu.showAtMouseEvent(evt); menu.showAtMouseEvent(evt);
}); });
// Drag events // Drag events — stopPropagation so nested children don't bubble to parent
itemEl.addEventListener('dragstart', (evt) => { itemEl.addEventListener('dragstart', (evt) => {
evt.stopPropagation();
callbacks.onDragStart(evt, todo); callbacks.onDragStart(evt, todo);
}); });
itemEl.addEventListener('dragend', (evt) => { itemEl.addEventListener('dragend', (evt) => {
evt.stopPropagation();
callbacks.onDragEnd(evt); callbacks.onDragEnd(evt);
}); });

View File

@@ -1,7 +1,7 @@
import { ItemView, Keymap, Platform, TFile, WorkspaceLeaf } from 'obsidian'; import { ItemView, Keymap, Platform, TFile, WorkspaceLeaf } from 'obsidian';
import type { TodoItem, TodoGroup } from '../core/types'; import type { TodoItem, TodoGroup } from '../core/types';
import { parseTodosGroupedByHeading } from '../core/todo-parser'; import { parseTodosGroupedByHeading } from '../core/todo-parser';
import { toggleTodo, moveTodoWithChildren, moveTodoUnderHeadingInFile } from '../core/todo-transformer'; import { toggleTodo, moveTodoWithChildren, moveTodoUnderHeadingInFile, indentTodoLines } from '../core/todo-transformer';
import { createTodoItemEl, collectChildLineNumbers } from './todo-item-component'; import { createTodoItemEl, collectChildLineNumbers } from './todo-item-component';
export const TODO_VIEW_TYPE = 'todo-tracker-view'; export const TODO_VIEW_TYPE = 'todo-tracker-view';
@@ -132,10 +132,7 @@ export class TodoSidebarView extends ItemView {
this.setupGroupDropZone(groupEl, group, file); this.setupGroupDropZone(groupEl, group, file);
} }
// Set initial focus index (Bug 1 fix: don't call container.focus()) // Don't auto-focus — user activates via ArrowDown (like Outline view)
if (this.flatTodoList.length > 0 && this.focusedIndex === -1) {
this.focusedIndex = 0;
}
// Clamp focus index if list shrank // Clamp focus index if list shrank
if (this.focusedIndex >= this.flatTodoList.length) { if (this.focusedIndex >= this.flatTodoList.length) {
this.focusedIndex = Math.max(0, this.flatTodoList.length - 1); this.focusedIndex = Math.max(0, this.flatTodoList.length - 1);
@@ -152,6 +149,7 @@ export class TodoSidebarView extends ItemView {
const itemEl = createTodoItemEl(container, todo, { const itemEl = createTodoItemEl(container, todo, {
onToggle: (t) => this.handleToggle(file, t), onToggle: (t) => this.handleToggle(file, t),
onMoveClick: (t) => this.handleMoveClick(t, file), onMoveClick: (t) => this.handleMoveClick(t, file),
onClick: (t) => this.openTodoInEditor(file, t),
onDragStart: (evt, t) => this.handleDragStart(evt, t), onDragStart: (evt, t) => this.handleDragStart(evt, t),
onDragEnd: (evt) => this.handleDragEnd(evt), onDragEnd: (evt) => this.handleDragEnd(evt),
}); });
@@ -215,12 +213,23 @@ export class TodoSidebarView extends ItemView {
this.contentEl.querySelectorAll('.todo-tracker-drag-over').forEach((el) => { this.contentEl.querySelectorAll('.todo-tracker-drag-over').forEach((el) => {
el.classList.remove('todo-tracker-drag-over'); el.classList.remove('todo-tracker-drag-over');
}); });
this.contentEl.querySelectorAll('.todo-tracker-drop-above, .todo-tracker-drop-below').forEach((el) => { this.contentEl.querySelectorAll('.todo-tracker-drop-above, .todo-tracker-drop-below, .todo-tracker-drop-nest').forEach((el) => {
el.classList.remove('todo-tracker-drop-above'); el.classList.remove('todo-tracker-drop-above');
el.classList.remove('todo-tracker-drop-below'); el.classList.remove('todo-tracker-drop-below');
el.classList.remove('todo-tracker-drop-nest');
}); });
} }
private getDropZone(evt: DragEvent, itemEl: HTMLElement): 'above' | 'nest' | 'below' {
const rect = itemEl.getBoundingClientRect();
const relativeY = evt.clientY - rect.top;
const thirdHeight = rect.height / 3;
if (relativeY < thirdHeight) return 'above';
if (relativeY < thirdHeight * 2) return 'nest';
return 'below';
}
private setupItemDropZone(itemEl: HTMLElement, targetTodo: TodoItem, file: TFile): void { private setupItemDropZone(itemEl: HTMLElement, targetTodo: TodoItem, file: TFile): void {
itemEl.addEventListener('dragover', (evt) => { itemEl.addEventListener('dragover', (evt) => {
if (!this.draggedTodo || this.draggedTodo.lineNumber === targetTodo.lineNumber) return; if (!this.draggedTodo || this.draggedTodo.lineNumber === targetTodo.lineNumber) return;
@@ -229,36 +238,39 @@ export class TodoSidebarView extends ItemView {
evt.dataTransfer.dropEffect = 'move'; evt.dataTransfer.dropEffect = 'move';
} }
// Determine drop position (above or below) const zone = this.getDropZone(evt, itemEl);
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-above');
itemEl.removeClass('todo-tracker-drop-below'); itemEl.removeClass('todo-tracker-drop-below');
itemEl.addClass(isAbove ? 'todo-tracker-drop-above' : 'todo-tracker-drop-below'); itemEl.removeClass('todo-tracker-drop-nest');
if (zone === 'above') itemEl.addClass('todo-tracker-drop-above');
else if (zone === 'below') itemEl.addClass('todo-tracker-drop-below');
else itemEl.addClass('todo-tracker-drop-nest');
}); });
itemEl.addEventListener('dragleave', () => { itemEl.addEventListener('dragleave', () => {
itemEl.removeClass('todo-tracker-drop-above'); itemEl.removeClass('todo-tracker-drop-above');
itemEl.removeClass('todo-tracker-drop-below'); itemEl.removeClass('todo-tracker-drop-below');
itemEl.removeClass('todo-tracker-drop-nest');
}); });
itemEl.addEventListener('drop', (evt) => { itemEl.addEventListener('drop', (evt) => {
evt.preventDefault(); evt.preventDefault();
itemEl.removeClass('todo-tracker-drop-above'); itemEl.removeClass('todo-tracker-drop-above');
itemEl.removeClass('todo-tracker-drop-below'); itemEl.removeClass('todo-tracker-drop-below');
itemEl.removeClass('todo-tracker-drop-nest');
if (!this.draggedTodo || !file) return; if (!this.draggedTodo || !file) return;
const rect = itemEl.getBoundingClientRect(); const zone = this.getDropZone(evt, itemEl);
const midY = rect.top + rect.height / 2;
const isAbove = evt.clientY < midY;
// Calculate target line: insert before or after the target todo if (zone === 'nest') {
const targetLine = isAbove ? targetTodo.lineNumber : targetTodo.lineNumber + 1; this.performNest(file, targetTodo);
} else {
this.performMove(file, targetLine); const targetLine = zone === 'above' ? targetTodo.lineNumber : targetTodo.lineNumber + 1;
this.performMove(file, targetLine);
}
}); });
} }
@@ -319,6 +331,55 @@ export class TodoSidebarView extends ItemView {
this.draggedChildLines = []; this.draggedChildLines = [];
} }
private async performNest(file: TFile, targetTodo: TodoItem): Promise<void> {
if (!this.draggedTodo) return;
const todoLine = this.draggedTodo.lineNumber;
const childLines = this.draggedChildLines;
const targetIndent = targetTodo.indentLevel;
await this.app.vault.process(file, (content) => {
const lines = content.split('\n');
const allLineNumbers = [todoLine, ...childLines].sort((a, b) => a - b);
// Extract lines to move
const movedLines = allLineNumbers.map((ln) => lines[ln]!);
// Calculate indent delta: we want dragged todo to be one level deeper than target
const draggedIndent = this.draggedTodo!.indentLevel;
const indentDelta = targetIndent + 1 - draggedIndent;
// Adjust indentation
const indentedLines = indentTodoLines(movedLines, indentDelta);
// Find insert position: right after target todo and its existing children
const targetChildLines = collectChildLineNumbers(targetTodo);
const allTargetLines = [targetTodo.lineNumber, ...targetChildLines];
const insertAfter = Math.max(...allTargetLines);
// Remove original lines (in reverse to preserve indices)
for (let i = allLineNumbers.length - 1; i >= 0; i--) {
lines.splice(allLineNumbers[i]!, 1);
}
// Adjust insert position for removed lines
let adjustedInsert = insertAfter;
for (const ln of allLineNumbers) {
if (ln <= insertAfter) {
adjustedInsert--;
}
}
// Insert indented lines after the target
lines.splice(adjustedInsert + 1, 0, ...indentedLines);
return lines.join('\n');
});
this.draggedTodo = null;
this.draggedChildLines = [];
}
private async performMoveUnderHeading( private async performMoveUnderHeading(
file: TFile, file: TFile,
headingLine: number, headingLine: number,
@@ -345,11 +406,21 @@ export class TodoSidebarView extends ItemView {
switch (evt.key) { switch (evt.key) {
case 'ArrowDown': case 'ArrowDown':
evt.preventDefault(); evt.preventDefault();
this.moveFocus(1); if (this.focusedIndex === -1) {
this.focusedIndex = 0;
this.updateFocusVisual();
} else {
this.moveFocus(1);
}
break; break;
case 'ArrowUp': case 'ArrowUp':
evt.preventDefault(); evt.preventDefault();
this.moveFocus(-1); if (this.focusedIndex === -1) {
this.focusedIndex = 0;
this.updateFocusVisual();
} else {
this.moveFocus(-1);
}
break; break;
case 'Enter': case 'Enter':
evt.preventDefault(); evt.preventDefault();
@@ -462,13 +533,15 @@ export class TodoSidebarView extends ItemView {
private openFocusedTodoInEditor(file: TFile): void { private openFocusedTodoInEditor(file: TFile): void {
const entry = this.flatTodoList[this.focusedIndex]; const entry = this.flatTodoList[this.focusedIndex];
if (!entry) return; if (!entry) return;
this.openTodoInEditor(file, entry.todo);
}
// Open the file and scroll to the line private openTodoInEditor(file: TFile, todo: TodoItem): void {
const leaf = this.app.workspace.getLeaf(false); const leaf = this.app.workspace.getLeaf(false);
leaf.openFile(file).then(() => { leaf.openFile(file).then(() => {
const editor = this.app.workspace.activeEditor?.editor; const editor = this.app.workspace.activeEditor?.editor;
if (editor) { if (editor) {
const line = entry.todo.lineNumber; const line = todo.lineNumber;
editor.setCursor({ line, ch: 0 }); editor.setCursor({ line, ch: 0 });
editor.scrollIntoView({ from: { line, ch: 0 }, to: { line, ch: 0 } }, true); editor.scrollIntoView({ from: { line, ch: 0 }, to: { line, ch: 0 } }, true);
} }

View File

@@ -103,6 +103,12 @@
border-bottom: 2px solid var(--interactive-accent); border-bottom: 2px solid var(--interactive-accent);
} }
.todo-tracker-drop-nest {
outline: 2px solid var(--interactive-accent);
outline-offset: -2px;
background-color: var(--background-modifier-hover);
}
.todo-tracker-drag-over { .todo-tracker-drag-over {
background-color: var(--background-modifier-hover); background-color: var(--background-modifier-hover);
} }