From a00b96231cf5aa62a1ac2052549ef55a54c8d6c2 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Fri, 20 Feb 2026 10:17:27 -0800 Subject: [PATCH] Add focus activation, 3-zone drag nesting, and click-to-navigate 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 --- CLAUDE.md | 46 ++++++++++++ src/core/todo-transformer.test.ts | 59 +++++++++++++++ src/core/todo-transformer.ts | 50 ++++++++++-- src/modals/heading-select-modal.ts | 3 +- src/modals/note-select-modal.ts | 2 +- src/views/todo-item-component.ts | 11 ++- src/views/todo-sidebar-view.ts | 117 +++++++++++++++++++++++------ styles.css | 6 ++ 8 files changed, 263 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1143dcf..7a442ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,3 +95,49 @@ From AGENTS.md and Obsidian best practices: - Keep startup lightweight; defer heavy work until needed - Use stable command IDs (don't rename after release) - 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 `
  • ` 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 `
  • ` and all nested `
  • ` 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) diff --git a/src/core/todo-transformer.test.ts b/src/core/todo-transformer.test.ts index 0bfba9e..ed5d3ec 100644 --- a/src/core/todo-transformer.test.ts +++ b/src/core/todo-transformer.test.ts @@ -6,6 +6,7 @@ import { insertTodoUnderHeading, moveTodoWithChildren, moveTodoUnderHeadingInFile, + indentTodoLines, } from './todo-transformer'; describe('toggleTodo', () => { @@ -256,3 +257,61 @@ describe('moveTodoUnderHeadingInFile', () => { 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'); + }); +}); diff --git a/src/core/todo-transformer.ts b/src/core/todo-transformer.ts index 05f6fa1..fca4d49 100644 --- a/src/core/todo-transformer.ts +++ b/src/core/todo-transformer.ts @@ -137,18 +137,47 @@ export function moveTodoUnderHeadingInFile( 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. * @param content - The file content * @param headingLineNumber - The line number of the heading (0-based) * @param todoLine - The todo line to insert * @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( content: string, headingLineNumber: number, todoLine: string, - nextHeadingLineNumber?: number + nextHeadingLineNumber?: number, + addBlankLine = false ): string { const lines = content.split('\n'); @@ -156,15 +185,26 @@ export function insertTodoUnderHeading( let insertPosition: number; if (nextHeadingLineNumber !== undefined) { - // Insert just before the next heading (at the end of this section) insertPosition = nextHeadingLineNumber; } else { - // No next heading - insert right after the heading line insertPosition = headingLineNumber + 1; } - // Insert the todo line - lines.splice(insertPosition, 0, todoLine); + if (addBlankLine) { + // 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'); } diff --git a/src/modals/heading-select-modal.ts b/src/modals/heading-select-modal.ts index 3eefdcd..6f256bd 100644 --- a/src/modals/heading-select-modal.ts +++ b/src/modals/heading-select-modal.ts @@ -79,7 +79,8 @@ export class HeadingSelectModal extends FuzzySuggestModal { content, selectedHeading.position.start.line, todoLine, - nextHeadingLine + nextHeadingLine, + true ); }); } else { diff --git a/src/modals/note-select-modal.ts b/src/modals/note-select-modal.ts index dfe1cbf..6497d14 100644 --- a/src/modals/note-select-modal.ts +++ b/src/modals/note-select-modal.ts @@ -54,7 +54,7 @@ export class NoteSelectModal extends FuzzySuggestModal { if (headingLineNumber !== undefined) { const { insertTodoUnderHeading } = await import('../core/todo-transformer'); await this.app.vault.process(targetFile, (content) => { - return insertTodoUnderHeading(content, headingLineNumber, todoLine, nextHeadingLineNumber); + return insertTodoUnderHeading(content, headingLineNumber, todoLine, nextHeadingLineNumber, true); }); } else { await this.app.vault.process(targetFile, (content) => { diff --git a/src/views/todo-item-component.ts b/src/views/todo-item-component.ts index 9ff3049..ead53d8 100644 --- a/src/views/todo-item-component.ts +++ b/src/views/todo-item-component.ts @@ -4,6 +4,7 @@ import type { TodoItem } from '../core/types'; export interface TodoItemCallbacks { onToggle: (todo: TodoItem) => void; onMoveClick: (todo: TodoItem) => void; + onClick: (todo: TodoItem) => void; onDragStart: (evt: DragEvent, todo: TodoItem) => void; onDragEnd: (evt: DragEvent) => void; } @@ -40,12 +41,16 @@ export function createTodoItemEl( callbacks.onToggle(todo); }); - // Text content + // Text content — click to navigate to editor const textEl = itemEl.createEl('span', { cls: 'todo-tracker-item-text' }); textEl.setText(todo.text); if (todo.completed) { textEl.addClass('todo-tracker-item-completed'); } + textEl.addEventListener('click', (evt) => { + evt.stopPropagation(); + callbacks.onClick(todo); + }); // Right-click context menu itemEl.addEventListener('contextmenu', (evt) => { @@ -65,12 +70,14 @@ export function createTodoItemEl( menu.showAtMouseEvent(evt); }); - // Drag events + // Drag events — stopPropagation so nested children don't bubble to parent itemEl.addEventListener('dragstart', (evt) => { + evt.stopPropagation(); callbacks.onDragStart(evt, todo); }); itemEl.addEventListener('dragend', (evt) => { + evt.stopPropagation(); callbacks.onDragEnd(evt); }); diff --git a/src/views/todo-sidebar-view.ts b/src/views/todo-sidebar-view.ts index 4180c5a..9810441 100644 --- a/src/views/todo-sidebar-view.ts +++ b/src/views/todo-sidebar-view.ts @@ -1,7 +1,7 @@ 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 { toggleTodo, moveTodoWithChildren, moveTodoUnderHeadingInFile, indentTodoLines } from '../core/todo-transformer'; import { createTodoItemEl, collectChildLineNumbers } from './todo-item-component'; export const TODO_VIEW_TYPE = 'todo-tracker-view'; @@ -132,10 +132,7 @@ export class TodoSidebarView extends ItemView { 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; - } + // Don't auto-focus — user activates via ArrowDown (like Outline view) // Clamp focus index if list shrank if (this.focusedIndex >= this.flatTodoList.length) { this.focusedIndex = Math.max(0, this.flatTodoList.length - 1); @@ -152,6 +149,7 @@ export class TodoSidebarView extends ItemView { const itemEl = createTodoItemEl(container, todo, { onToggle: (t) => this.handleToggle(file, t), onMoveClick: (t) => this.handleMoveClick(t, file), + onClick: (t) => this.openTodoInEditor(file, t), onDragStart: (evt, t) => this.handleDragStart(evt, t), onDragEnd: (evt) => this.handleDragEnd(evt), }); @@ -215,12 +213,23 @@ export class TodoSidebarView extends ItemView { 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) => { + 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-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 { itemEl.addEventListener('dragover', (evt) => { if (!this.draggedTodo || this.draggedTodo.lineNumber === targetTodo.lineNumber) return; @@ -229,36 +238,39 @@ export class TodoSidebarView extends ItemView { 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; + const zone = this.getDropZone(evt, itemEl); itemEl.removeClass('todo-tracker-drop-above'); 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.removeClass('todo-tracker-drop-above'); itemEl.removeClass('todo-tracker-drop-below'); + itemEl.removeClass('todo-tracker-drop-nest'); }); itemEl.addEventListener('drop', (evt) => { evt.preventDefault(); itemEl.removeClass('todo-tracker-drop-above'); itemEl.removeClass('todo-tracker-drop-below'); + itemEl.removeClass('todo-tracker-drop-nest'); if (!this.draggedTodo || !file) return; - const rect = itemEl.getBoundingClientRect(); - const midY = rect.top + rect.height / 2; - const isAbove = evt.clientY < midY; + const zone = this.getDropZone(evt, itemEl); - // Calculate target line: insert before or after the target todo - const targetLine = isAbove ? targetTodo.lineNumber : targetTodo.lineNumber + 1; - - this.performMove(file, targetLine); + if (zone === 'nest') { + this.performNest(file, targetTodo); + } else { + const targetLine = zone === 'above' ? targetTodo.lineNumber : targetTodo.lineNumber + 1; + this.performMove(file, targetLine); + } }); } @@ -319,6 +331,55 @@ export class TodoSidebarView extends ItemView { this.draggedChildLines = []; } + private async performNest(file: TFile, targetTodo: TodoItem): Promise { + 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( file: TFile, headingLine: number, @@ -345,11 +406,21 @@ export class TodoSidebarView extends ItemView { switch (evt.key) { case 'ArrowDown': evt.preventDefault(); - this.moveFocus(1); + if (this.focusedIndex === -1) { + this.focusedIndex = 0; + this.updateFocusVisual(); + } else { + this.moveFocus(1); + } break; case 'ArrowUp': evt.preventDefault(); - this.moveFocus(-1); + if (this.focusedIndex === -1) { + this.focusedIndex = 0; + this.updateFocusVisual(); + } else { + this.moveFocus(-1); + } break; case 'Enter': evt.preventDefault(); @@ -462,13 +533,15 @@ export class TodoSidebarView extends ItemView { private openFocusedTodoInEditor(file: TFile): void { const entry = this.flatTodoList[this.focusedIndex]; 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); leaf.openFile(file).then(() => { const editor = this.app.workspace.activeEditor?.editor; if (editor) { - const line = entry.todo.lineNumber; + const line = todo.lineNumber; editor.setCursor({ line, ch: 0 }); editor.scrollIntoView({ from: { line, ch: 0 }, to: { line, ch: 0 } }, true); } diff --git a/styles.css b/styles.css index 43a38cc..4561b77 100644 --- a/styles.css +++ b/styles.css @@ -103,6 +103,12 @@ 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 { background-color: var(--background-modifier-hover); }