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:
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseTodos } from './todo-parser';
|
||||
import { parseTodos, parseIndentLevel, buildTodoTree, parseTodosGroupedByHeading } from './todo-parser';
|
||||
import type { TodoItem } from './types';
|
||||
|
||||
describe('parseTodos', () => {
|
||||
it('should parse unchecked todos with dash marker', () => {
|
||||
@@ -12,6 +13,8 @@ describe('parseTodos', () => {
|
||||
completed: false,
|
||||
lineNumber: 0,
|
||||
rawLine: '- [ ] Buy groceries',
|
||||
indentLevel: 0,
|
||||
children: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,4 +124,209 @@ describe('parseTodos', () => {
|
||||
expect(result.todos).toHaveLength(1);
|
||||
expect(result.todos[0]?.text).toBe('Task with `code` and **bold** and [link](url)');
|
||||
});
|
||||
|
||||
it('should set indentLevel for indented todos', () => {
|
||||
const content = '- [ ] Top level\n - [ ] One indent\n - [ ] Two indent';
|
||||
const result = parseTodos(content);
|
||||
expect(result.todos[0]?.indentLevel).toBe(0);
|
||||
expect(result.todos[1]?.indentLevel).toBe(1);
|
||||
expect(result.todos[2]?.indentLevel).toBe(2);
|
||||
});
|
||||
|
||||
it('should set indentLevel for tab-indented todos', () => {
|
||||
const content = '- [ ] Top\n\t- [ ] One tab\n\t\t- [ ] Two tabs';
|
||||
const result = parseTodos(content);
|
||||
expect(result.todos[0]?.indentLevel).toBe(0);
|
||||
expect(result.todos[1]?.indentLevel).toBe(1);
|
||||
expect(result.todos[2]?.indentLevel).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseIndentLevel', () => {
|
||||
it('should return 0 for no indentation', () => {
|
||||
expect(parseIndentLevel('- [ ] Hello')).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 1 for 2-space indent', () => {
|
||||
expect(parseIndentLevel(' - [ ] Hello')).toBe(1);
|
||||
});
|
||||
|
||||
it('should return 2 for 4-space indent', () => {
|
||||
expect(parseIndentLevel(' - [ ] Hello')).toBe(2);
|
||||
});
|
||||
|
||||
it('should return 1 for single tab indent', () => {
|
||||
expect(parseIndentLevel('\t- [ ] Hello')).toBe(1);
|
||||
});
|
||||
|
||||
it('should return 2 for double tab indent', () => {
|
||||
expect(parseIndentLevel('\t\t- [ ] Hello')).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTodoTree', () => {
|
||||
function makeTodo(lineNumber: number, indentLevel: number, text: string): TodoItem {
|
||||
return {
|
||||
id: `line-${lineNumber}`,
|
||||
text,
|
||||
completed: false,
|
||||
lineNumber,
|
||||
rawLine: `${' '.repeat(indentLevel)}- [ ] ${text}`,
|
||||
indentLevel,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
it('should return empty array for empty input', () => {
|
||||
expect(buildTodoTree([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return flat list when all same indent level', () => {
|
||||
const todos = [makeTodo(0, 0, 'A'), makeTodo(1, 0, 'B')];
|
||||
const tree = buildTodoTree(todos);
|
||||
expect(tree).toHaveLength(2);
|
||||
expect(tree[0]?.text).toBe('A');
|
||||
expect(tree[1]?.text).toBe('B');
|
||||
});
|
||||
|
||||
it('should nest a child under its parent', () => {
|
||||
const todos = [makeTodo(0, 0, 'Parent'), makeTodo(1, 1, 'Child')];
|
||||
const tree = buildTodoTree(todos);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0]?.text).toBe('Parent');
|
||||
expect(tree[0]?.children).toHaveLength(1);
|
||||
expect(tree[0]?.children[0]?.text).toBe('Child');
|
||||
});
|
||||
|
||||
it('should nest deeply', () => {
|
||||
const todos = [
|
||||
makeTodo(0, 0, 'Root'),
|
||||
makeTodo(1, 1, 'Child'),
|
||||
makeTodo(2, 2, 'Grandchild'),
|
||||
];
|
||||
const tree = buildTodoTree(todos);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0]?.children[0]?.children[0]?.text).toBe('Grandchild');
|
||||
});
|
||||
|
||||
it('should handle siblings after nested children', () => {
|
||||
const todos = [
|
||||
makeTodo(0, 0, 'First'),
|
||||
makeTodo(1, 1, 'Child of First'),
|
||||
makeTodo(2, 0, 'Second'),
|
||||
];
|
||||
const tree = buildTodoTree(todos);
|
||||
expect(tree).toHaveLength(2);
|
||||
expect(tree[0]?.children).toHaveLength(1);
|
||||
expect(tree[1]?.text).toBe('Second');
|
||||
expect(tree[1]?.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle multiple children under same parent', () => {
|
||||
const todos = [
|
||||
makeTodo(0, 0, 'Parent'),
|
||||
makeTodo(1, 1, 'Child A'),
|
||||
makeTodo(2, 1, 'Child B'),
|
||||
];
|
||||
const tree = buildTodoTree(todos);
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0]?.children).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTodosGroupedByHeading', () => {
|
||||
it('should return empty array for empty content', () => {
|
||||
expect(parseTodosGroupedByHeading('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should group todos under null heading when no headings exist', () => {
|
||||
const content = '- [ ] Task A\n- [ ] Task B';
|
||||
const groups = parseTodosGroupedByHeading(content);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]?.heading).toBeNull();
|
||||
expect(groups[0]?.todos).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should group todos under their heading', () => {
|
||||
const content = '## Heading 1\n- [ ] Task A\n## Heading 2\n- [ ] Task B';
|
||||
const groups = parseTodosGroupedByHeading(content);
|
||||
// First group is null heading (empty, before any heading)
|
||||
expect(groups).toHaveLength(3);
|
||||
expect(groups[0]?.heading).toBeNull();
|
||||
expect(groups[0]?.todos).toHaveLength(0);
|
||||
expect(groups[1]?.heading?.text).toBe('Heading 1');
|
||||
expect(groups[1]?.todos).toHaveLength(1);
|
||||
expect(groups[1]?.todos[0]?.text).toBe('Task A');
|
||||
expect(groups[2]?.heading?.text).toBe('Heading 2');
|
||||
expect(groups[2]?.todos).toHaveLength(1);
|
||||
expect(groups[2]?.todos[0]?.text).toBe('Task B');
|
||||
});
|
||||
|
||||
it('should show headings with no todos', () => {
|
||||
const content = '## Heading 1\nSome text\n## Heading 2\n- [ ] Task B';
|
||||
const groups = parseTodosGroupedByHeading(content);
|
||||
expect(groups).toHaveLength(3);
|
||||
expect(groups[1]?.heading?.text).toBe('Heading 1');
|
||||
expect(groups[1]?.todos).toHaveLength(0);
|
||||
expect(groups[2]?.heading?.text).toBe('Heading 2');
|
||||
expect(groups[2]?.todos).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle todos before first heading', () => {
|
||||
const content = '- [ ] Before heading\n## My Heading\n- [ ] After heading';
|
||||
const groups = parseTodosGroupedByHeading(content);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0]?.heading).toBeNull();
|
||||
expect(groups[0]?.todos).toHaveLength(1);
|
||||
expect(groups[0]?.todos[0]?.text).toBe('Before heading');
|
||||
expect(groups[1]?.heading?.text).toBe('My Heading');
|
||||
expect(groups[1]?.todos[0]?.text).toBe('After heading');
|
||||
});
|
||||
|
||||
it('should nest children within groups', () => {
|
||||
const content = '## Tasks\n- [ ] Parent\n - [ ] Child';
|
||||
const groups = parseTodosGroupedByHeading(content);
|
||||
const taskGroup = groups[1]!;
|
||||
expect(taskGroup.todos).toHaveLength(1);
|
||||
expect(taskGroup.todos[0]?.text).toBe('Parent');
|
||||
expect(taskGroup.todos[0]?.children).toHaveLength(1);
|
||||
expect(taskGroup.todos[0]?.children[0]?.text).toBe('Child');
|
||||
});
|
||||
|
||||
it('should preserve heading level and line number', () => {
|
||||
const content = '# H1\n## H2\n### H3';
|
||||
const groups = parseTodosGroupedByHeading(content);
|
||||
expect(groups).toHaveLength(4);
|
||||
expect(groups[1]?.heading?.level).toBe(1);
|
||||
expect(groups[1]?.heading?.lineNumber).toBe(0);
|
||||
expect(groups[2]?.heading?.level).toBe(2);
|
||||
expect(groups[2]?.heading?.lineNumber).toBe(1);
|
||||
expect(groups[3]?.heading?.level).toBe(3);
|
||||
expect(groups[3]?.heading?.lineNumber).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle complex real-world content', () => {
|
||||
const content = [
|
||||
'# Hello World',
|
||||
'',
|
||||
'## Heading 2',
|
||||
'- [ ] Hello World',
|
||||
'',
|
||||
'## Heading 3',
|
||||
'- [ ] Buy groceries',
|
||||
'- [ ] Make lunch',
|
||||
' - [ ] Prep ingredients',
|
||||
'- [ ] Make a new playtest',
|
||||
].join('\n');
|
||||
|
||||
const groups = parseTodosGroupedByHeading(content);
|
||||
// null group, H1, H2, H3
|
||||
expect(groups).toHaveLength(4);
|
||||
expect(groups[2]?.heading?.text).toBe('Heading 2');
|
||||
expect(groups[2]?.todos).toHaveLength(1);
|
||||
expect(groups[3]?.heading?.text).toBe('Heading 3');
|
||||
expect(groups[3]?.todos).toHaveLength(3); // top-level only
|
||||
expect(groups[3]?.todos[1]?.children).toHaveLength(1); // Make lunch has child
|
||||
expect(groups[3]?.todos[1]?.children[0]?.text).toBe('Prep ingredients');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ParseResult, TodoItem } from './types';
|
||||
import type { ParseResult, TodoItem, TodoGroup, HeadingInfo } from './types';
|
||||
|
||||
/**
|
||||
* Regex pattern for markdown todo checkboxes.
|
||||
@@ -6,6 +6,118 @@ import type { ParseResult, TodoItem } from './types';
|
||||
*/
|
||||
const TODO_PATTERN = /^(\s*[-*+]\s+\[)([xX ]?)(\]\s+)(.*)$/;
|
||||
|
||||
const HEADING_PATTERN = /^(#{1,6})\s+(.+)$/;
|
||||
|
||||
/**
|
||||
* Parse the indentation level of a line.
|
||||
* A tab counts as 1 level. Spaces use 2-space or 4-space detection (default 4).
|
||||
*/
|
||||
export function parseIndentLevel(line: string): number {
|
||||
const leadingWhitespace = line.match(/^(\s*)/)?.[1] ?? '';
|
||||
if (leadingWhitespace.length === 0) return 0;
|
||||
|
||||
let level = 0;
|
||||
for (const ch of leadingWhitespace) {
|
||||
if (ch === '\t') {
|
||||
level += 1;
|
||||
}
|
||||
}
|
||||
if (level > 0) return level;
|
||||
|
||||
// Space-based indentation: detect indent size from the line
|
||||
// Obsidian typically uses tab or 2/4 spaces for list nesting
|
||||
// We'll count by the smallest indent unit we find
|
||||
const spaceCount = leadingWhitespace.length;
|
||||
// Default to treating each 2 spaces as 1 level (common in Obsidian)
|
||||
return Math.floor(spaceCount / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a tree of todos from a flat list based on indentLevel.
|
||||
* Todos with higher indent levels become children of the nearest preceding todo with a lower indent level.
|
||||
*/
|
||||
export function buildTodoTree(flatTodos: TodoItem[]): TodoItem[] {
|
||||
if (flatTodos.length === 0) return [];
|
||||
|
||||
const roots: TodoItem[] = [];
|
||||
const stack: TodoItem[] = [];
|
||||
|
||||
for (const todo of flatTodos) {
|
||||
// Make a copy with empty children to avoid mutation
|
||||
const node: TodoItem = { ...todo, children: [] };
|
||||
|
||||
// Pop stack until we find a parent with lower indent level
|
||||
while (stack.length > 0 && stack[stack.length - 1]!.indentLevel >= node.indentLevel) {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
if (stack.length === 0) {
|
||||
roots.push(node);
|
||||
} else {
|
||||
stack[stack.length - 1]!.children.push(node);
|
||||
}
|
||||
|
||||
stack.push(node);
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a heading line, returning HeadingInfo or null.
|
||||
*/
|
||||
export function parseHeadingLine(line: string, lineNumber: number): HeadingInfo | null {
|
||||
const match = line.match(HEADING_PATTERN);
|
||||
if (!match) return null;
|
||||
return {
|
||||
text: match[2]!.trim(),
|
||||
level: match[1]!.length,
|
||||
lineNumber,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse content and return todos grouped by heading, with nested children.
|
||||
* All headings are included, even those with no todos.
|
||||
*/
|
||||
export function parseTodosGroupedByHeading(content: string): TodoGroup[] {
|
||||
if (!content) return [];
|
||||
|
||||
const lines = content.split('\n');
|
||||
const groups: TodoGroup[] = [];
|
||||
let currentGroup: TodoGroup = { heading: null, todos: [] };
|
||||
let currentFlatTodos: TodoItem[] = [];
|
||||
|
||||
const flushGroup = () => {
|
||||
currentGroup.todos = buildTodoTree(currentFlatTodos);
|
||||
groups.push(currentGroup);
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line === undefined) continue;
|
||||
|
||||
const heading = parseHeadingLine(line, i);
|
||||
if (heading) {
|
||||
// Flush previous group
|
||||
flushGroup();
|
||||
currentGroup = { heading, todos: [] };
|
||||
currentFlatTodos = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
const todo = parseTodoLine(line, i);
|
||||
if (todo) {
|
||||
currentFlatTodos.push(todo);
|
||||
}
|
||||
}
|
||||
|
||||
// Flush the last group
|
||||
flushGroup();
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single line and return a TodoItem if it's a todo, null otherwise.
|
||||
*/
|
||||
@@ -24,6 +136,8 @@ export function parseTodoLine(line: string, lineNumber: number): TodoItem | null
|
||||
completed: checkboxContent === 'x' || checkboxContent === 'X',
|
||||
lineNumber,
|
||||
rawLine: line,
|
||||
indentLevel: parseIndentLevel(line),
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
removeTodoLine,
|
||||
insertTodoAtEnd,
|
||||
insertTodoUnderHeading,
|
||||
moveTodoWithChildren,
|
||||
moveTodoUnderHeadingInFile,
|
||||
} from './todo-transformer';
|
||||
|
||||
describe('toggleTodo', () => {
|
||||
@@ -181,3 +183,76 @@ describe('insertTodoUnderHeading', () => {
|
||||
expect(result).toBe('# Heading\n - [ ] Indented\nContent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveTodoWithChildren', () => {
|
||||
it('should move a single todo (no children) to a new position', () => {
|
||||
const content = '- [ ] A\n- [ ] B\n- [ ] C';
|
||||
// Move A (line 0) to after C (target=3, insert before line 3 = end)
|
||||
const result = moveTodoWithChildren(content, 0, [], 3);
|
||||
expect(result).toBe('- [ ] B\n- [ ] C\n- [ ] A');
|
||||
});
|
||||
|
||||
it('should move a todo with children', () => {
|
||||
const content = '- [ ] A\n - [ ] A1\n- [ ] B\n- [ ] C';
|
||||
// Move A + A1 (lines 0,1) to after C (target=4, insert before line 4 = end)
|
||||
const result = moveTodoWithChildren(content, 0, [1], 4);
|
||||
expect(result).toBe('- [ ] B\n- [ ] C\n- [ ] A\n - [ ] A1');
|
||||
});
|
||||
|
||||
it('should move a todo upward', () => {
|
||||
const content = '- [ ] A\n- [ ] B\n- [ ] C';
|
||||
// Move C (line 2) to position 0 (before A)
|
||||
const result = moveTodoWithChildren(content, 2, [], 0);
|
||||
expect(result).toBe('- [ ] C\n- [ ] A\n- [ ] B');
|
||||
});
|
||||
|
||||
it('should move todo with children upward', () => {
|
||||
const content = '- [ ] A\n- [ ] B\n - [ ] B1\n- [ ] C';
|
||||
// Move B + B1 (lines 1,2) to position 0
|
||||
const result = moveTodoWithChildren(content, 1, [2], 0);
|
||||
expect(result).toBe('- [ ] B\n - [ ] B1\n- [ ] A\n- [ ] C');
|
||||
});
|
||||
|
||||
it('should handle moving to same position (no-op)', () => {
|
||||
const content = '- [ ] A\n- [ ] B\n- [ ] C';
|
||||
const result = moveTodoWithChildren(content, 1, [], 1);
|
||||
expect(result).toBe('- [ ] A\n- [ ] B\n- [ ] C');
|
||||
});
|
||||
|
||||
it('should handle content with non-todo lines', () => {
|
||||
const content = '# Heading\n- [ ] A\nSome text\n- [ ] B';
|
||||
// Move B (line 3) to position 1 (after heading, before A)
|
||||
const result = moveTodoWithChildren(content, 3, [], 1);
|
||||
expect(result).toBe('# Heading\n- [ ] B\n- [ ] A\nSome text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveTodoUnderHeadingInFile', () => {
|
||||
it('should move a todo to end of heading section', () => {
|
||||
const content = '## H1\n- [ ] A\n## H2\n- [ ] B';
|
||||
// Move A (line 1) under H2 (heading at line 2, no next heading)
|
||||
const result = moveTodoUnderHeadingInFile(content, 1, [], 2);
|
||||
expect(result).toBe('## H1\n## H2\n- [ ] B\n- [ ] A');
|
||||
});
|
||||
|
||||
it('should move todo under heading with next heading boundary', () => {
|
||||
const content = '## H1\n- [ ] A\n## H2\n- [ ] B\n## H3\n- [ ] C';
|
||||
// Move A (line 1) under H2 (heading at line 2, next heading at line 4)
|
||||
const result = moveTodoUnderHeadingInFile(content, 1, [], 2, 4);
|
||||
expect(result).toBe('## H1\n## H2\n- [ ] B\n- [ ] A\n## H3\n- [ ] C');
|
||||
});
|
||||
|
||||
it('should move todo with children under a heading', () => {
|
||||
const content = '## H1\n- [ ] A\n - [ ] A1\n## H2\n- [ ] B';
|
||||
// Move A + A1 (lines 1,2) under H2 (heading at line 3)
|
||||
const result = moveTodoUnderHeadingInFile(content, 1, [2], 3);
|
||||
expect(result).toBe('## H1\n## H2\n- [ ] B\n- [ ] A\n - [ ] A1');
|
||||
});
|
||||
|
||||
it('should move todo under empty heading section', () => {
|
||||
const content = '## H1\n- [ ] A\n## H2\n## H3';
|
||||
// Move A (line 1) under H2 (heading at line 2, next heading at line 3)
|
||||
const result = moveTodoUnderHeadingInFile(content, 1, [], 2, 3);
|
||||
expect(result).toBe('## H1\n## H2\n- [ ] A\n## H3');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,6 +65,78 @@ export function insertTodoAtEnd(content: string, todoLine: string): string {
|
||||
return content + '\n' + todoLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a todo (and its children) from one position to another within the same content.
|
||||
* @param content - The file content
|
||||
* @param todoLineNumber - Line number of the todo to move
|
||||
* @param childLineNumbers - Line numbers of child todos (must be consecutive after todoLineNumber)
|
||||
* @param targetLineNumber - Line number to insert before (0-based). Lines are inserted at this position.
|
||||
*/
|
||||
export function moveTodoWithChildren(
|
||||
content: string,
|
||||
todoLineNumber: number,
|
||||
childLineNumbers: number[],
|
||||
targetLineNumber: number
|
||||
): string {
|
||||
const lines = content.split('\n');
|
||||
const allLineNumbers = [todoLineNumber, ...childLineNumbers].sort((a, b) => a - b);
|
||||
|
||||
// Extract the lines to move
|
||||
const movedLines = allLineNumbers.map((ln) => lines[ln]!);
|
||||
|
||||
// If moving to same position, no-op
|
||||
if (targetLineNumber === todoLineNumber) {
|
||||
return content;
|
||||
}
|
||||
|
||||
// Remove lines from original positions (in reverse to preserve indices)
|
||||
for (let i = allLineNumbers.length - 1; i >= 0; i--) {
|
||||
lines.splice(allLineNumbers[i]!, 1);
|
||||
}
|
||||
|
||||
// Adjust target position for removed lines
|
||||
let adjustedTarget = targetLineNumber;
|
||||
for (const ln of allLineNumbers) {
|
||||
if (ln < targetLineNumber) {
|
||||
adjustedTarget--;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert at adjusted target
|
||||
lines.splice(adjustedTarget, 0, ...movedLines);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a todo (and its children) under a heading within the same file.
|
||||
* Inserts at the end of the heading's section.
|
||||
* @param content - The file content
|
||||
* @param todoLineNumber - Line number of the todo to move
|
||||
* @param childLineNumbers - Line numbers of child todos
|
||||
* @param headingLineNumber - Line number of the target heading
|
||||
* @param nextHeadingLineNumber - Optional line number of the next heading (section boundary)
|
||||
*/
|
||||
export function moveTodoUnderHeadingInFile(
|
||||
content: string,
|
||||
todoLineNumber: number,
|
||||
childLineNumbers: number[],
|
||||
headingLineNumber: number,
|
||||
nextHeadingLineNumber?: number
|
||||
): string {
|
||||
// Determine insert position: end of section (before next heading) or after heading
|
||||
let targetLineNumber: number;
|
||||
if (nextHeadingLineNumber !== undefined) {
|
||||
targetLineNumber = nextHeadingLineNumber;
|
||||
} else {
|
||||
// No next heading — insert at end of file
|
||||
const lines = content.split('\n');
|
||||
targetLineNumber = lines.length;
|
||||
}
|
||||
|
||||
return moveTodoWithChildren(content, todoLineNumber, childLineNumbers, targetLineNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a todo line under a specific heading.
|
||||
* @param content - The file content
|
||||
|
||||
@@ -12,6 +12,10 @@ export interface TodoItem {
|
||||
lineNumber: number;
|
||||
/** The full original line text (for accurate replacement) */
|
||||
rawLine: string;
|
||||
/** Indentation level (0 = top-level, 1 = one indent, etc.) */
|
||||
indentLevel: number;
|
||||
/** Nested child todos */
|
||||
children: TodoItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,6 +25,16 @@ export interface ParseResult {
|
||||
todos: TodoItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A group of todos under a heading (or before any heading)
|
||||
*/
|
||||
export interface TodoGroup {
|
||||
/** The heading this group belongs to, or null for todos before any heading */
|
||||
heading: HeadingInfo | null;
|
||||
/** Top-level todos in this group (children nested within) */
|
||||
todos: TodoItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a heading in a note
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
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),
|
||||
});
|
||||
listEl.appendChild(itemEl);
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
67
styles.css
67
styles.css
@@ -2,6 +2,7 @@
|
||||
|
||||
.todo-tracker-container {
|
||||
padding: 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.todo-tracker-header {
|
||||
@@ -20,18 +21,45 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Heading Groups */
|
||||
.todo-tracker-group {
|
||||
margin-bottom: 12px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.todo-tracker-group-heading {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 4px 4px 0;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* Todo Lists */
|
||||
.todo-tracker-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.todo-tracker-nested-list {
|
||||
list-style: none;
|
||||
padding-left: 20px;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Todo Items */
|
||||
.todo-tracker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 6px 4px;
|
||||
border-radius: 4px;
|
||||
cursor: grab;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.todo-tracker-item:hover {
|
||||
@@ -55,23 +83,26 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.todo-tracker-menu-btn {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.todo-tracker-item:hover .todo-tracker-menu-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.todo-tracker-menu-btn:hover {
|
||||
/* Keyboard focus */
|
||||
.todo-tracker-item-focused {
|
||||
outline: 2px solid var(--interactive-accent);
|
||||
outline-offset: -2px;
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
/* Drag and drop */
|
||||
.todo-tracker-item-dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.todo-tracker-drop-above {
|
||||
border-top: 2px solid var(--interactive-accent);
|
||||
}
|
||||
|
||||
.todo-tracker-drop-below {
|
||||
border-bottom: 2px solid var(--interactive-accent);
|
||||
}
|
||||
|
||||
.todo-tracker-drag-over {
|
||||
background-color: var(--background-modifier-hover);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user