mirror of
https://github.com/brendan-ch/todo-tracker-obsidian.git
synced 2026-04-19 08:10:29 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<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(
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user