mirror of
https://github.com/brendan-ch/todo-tracker-obsidian.git
synced 2026-04-16 23:00:32 +00:00
360 lines
9.9 KiB
TypeScript
360 lines
9.9 KiB
TypeScript
import { App, ItemView, Platform, TFile, WorkspaceLeaf } from 'obsidian';
|
|
import type { TodoItem, TodoGroup } from '../core/types';
|
|
import { parseTodosGroupedByHeading } from '../core/todo-parser';
|
|
import { toggleTodo } from '../core/todo-transformer';
|
|
import { createTodoItemEl } 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[] = [];
|
|
|
|
constructor(leaf: WorkspaceLeaf) {
|
|
super(leaf);
|
|
}
|
|
|
|
getViewType(): string {
|
|
return TODO_VIEW_TYPE;
|
|
}
|
|
|
|
getDisplayText(): string {
|
|
return 'Todo tracker';
|
|
}
|
|
|
|
getIcon(): string {
|
|
return 'check-square';
|
|
}
|
|
|
|
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();
|
|
|
|
this.registerEvent(
|
|
this.app.workspace.on('active-leaf-change', () => {
|
|
void this.refresh();
|
|
})
|
|
);
|
|
|
|
this.registerEvent(
|
|
this.app.vault.on('modify', (file) => {
|
|
if (this.currentFile && file.path === this.currentFile.path) {
|
|
void this.refresh();
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
async onClose(): Promise<void> {
|
|
// Cleanup is handled automatically by registerEvent
|
|
}
|
|
|
|
async refresh(): Promise<void> {
|
|
const activeFile = this.app.workspace.getActiveFile();
|
|
|
|
if (!activeFile || activeFile.extension !== 'md') {
|
|
this.currentFile = null;
|
|
this.renderEmpty('Open a markdown file to see its todos');
|
|
return;
|
|
}
|
|
|
|
this.currentFile = activeFile;
|
|
|
|
const content = await this.app.vault.read(activeFile);
|
|
this.groups = parseTodosGroupedByHeading(content);
|
|
|
|
// 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.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 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);
|
|
|
|
// Build flat todo list for keyboard navigation
|
|
this.flatTodoList = [];
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
this.updateFocusVisual();
|
|
}
|
|
|
|
private renderTodoItem(
|
|
container: HTMLElement,
|
|
todo: TodoItem,
|
|
file: TFile,
|
|
group: TodoGroup
|
|
): void {
|
|
const itemEl = createTodoItemEl(container, todo, {
|
|
onToggle: (t) => { void this.handleToggle(file, t); },
|
|
onMoveClick: (t) => this.handleMoveClick(t, file),
|
|
onMoveDailyNoteClick: (t) => this.handleMoveDailyNoteClick(t, file),
|
|
onClick: (t) => this.openTodoInEditor(file, t),
|
|
});
|
|
|
|
// Register in flat list for keyboard navigation
|
|
this.flatTodoList.push({ todo, element: itemEl, group });
|
|
|
|
// 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<HTMLElement>(
|
|
`li[data-line-number="${todo.lineNumber}"]`
|
|
);
|
|
if (childEl) {
|
|
this.flatTodoList.push({ todo, element: childEl, group });
|
|
|
|
for (const grandchild of todo.children) {
|
|
this.registerChildInFlatList(childEl, grandchild, group);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Keyboard Navigation ---
|
|
|
|
private handleKeydown(evt: KeyboardEvent): void {
|
|
if (this.flatTodoList.length === 0 || !this.currentFile) return;
|
|
|
|
switch (evt.key) {
|
|
case 'ArrowDown':
|
|
evt.preventDefault();
|
|
if (this.focusedIndex === -1) {
|
|
this.focusedIndex = 0;
|
|
this.updateFocusVisual();
|
|
} else {
|
|
this.moveFocus(1);
|
|
}
|
|
break;
|
|
case 'ArrowUp':
|
|
evt.preventDefault();
|
|
if (this.focusedIndex === -1) {
|
|
this.focusedIndex = 0;
|
|
this.updateFocusVisual();
|
|
} else {
|
|
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 {
|
|
interface HotkeyDef { modifiers: string[]; key: string; }
|
|
interface HotkeyManager {
|
|
getHotkeys(commandId: string): HotkeyDef[];
|
|
getDefaultHotkeys(commandId: string): HotkeyDef[];
|
|
}
|
|
const hotkeyManager = (this.app as App & { hotkeyManager?: HotkeyManager }).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;
|
|
|
|
// 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;
|
|
this.openTodoInEditor(file, entry.todo);
|
|
}
|
|
|
|
private openTodoInEditor(file: TFile, todo: TodoItem): void {
|
|
const leaf = this.app.workspace.getLeaf(false);
|
|
void leaf.openFile(file).then(() => {
|
|
const editor = this.app.workspace.activeEditor?.editor;
|
|
if (editor) {
|
|
const line = 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;
|
|
void 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);
|
|
});
|
|
}
|
|
|
|
private handleMoveClick(todo: TodoItem, file: TFile): void {
|
|
void import('../modals/note-select-modal').then(({ NoteSelectModal }) => {
|
|
new NoteSelectModal(this.app, todo, file).open();
|
|
});
|
|
}
|
|
|
|
private handleMoveDailyNoteClick(t: TodoItem, file: TFile): void {
|
|
|
|
}
|
|
}
|