import { Notice } from 'obsidian'; export class KeyboardNavigator { private container: HTMLElement | null = null; private table: HTMLTableElement | null = null; private row = 0; private col = 0; private rows: HTMLTableRowElement[] = []; private active = false; private searchMode = false; private searchOverlay: HTMLElement | null = null; private searchInput: HTMLInputElement | null = null; private searchQuery = ''; private boundHandler: (e: KeyboardEvent) => void; constructor(private app: { workspace: { openLinkText(link: string, source: string): void } }) { this.boundHandler = this.handleKeydown.bind(this); } attach(container: HTMLElement): boolean { const table = container.querySelector('table.logfire-table'); if (!table) return false; this.container = container; this.table = table; this.rows = Array.from(table.querySelectorAll('tbody tr')); if (this.rows.length === 0) return false; this.active = true; this.row = 0; this.col = 0; container.tabIndex = 0; container.focus(); container.addEventListener('keydown', this.boundHandler); this.highlight(); return true; } detach(): void { this.clearHighlight(); this.closeSearch(); if (this.container) { this.container.removeEventListener('keydown', this.boundHandler); } this.active = false; this.container = null; this.table = null; this.rows = []; } isActive(): boolean { return this.active; } // --------------------------------------------------------------------------- // Keydown handler // --------------------------------------------------------------------------- private handleKeydown(e: KeyboardEvent): void { if (!this.active) return; if (this.searchMode) { this.handleSearchKey(e); return; } switch (e.key) { case 'j': this.move(1, 0); break; case 'k': this.move(-1, 0); break; case 'l': this.move(0, 1); break; case 'h': this.move(0, -1); break; case 'g': if (this.pendingG) { this.goTop(); this.pendingG = false; } else { this.pendingG = true; setTimeout(() => { this.pendingG = false; }, 500); return; } break; case 'G': this.goBottom(); break; case '/': this.openSearch(); break; case 'y': this.yankCell(); break; case 'Enter': this.openFile(); break; case 'Escape': this.detach(); break; default: return; } e.preventDefault(); e.stopPropagation(); } private pendingG = false; // --------------------------------------------------------------------------- // Movement // --------------------------------------------------------------------------- private move(dr: number, dc: number): void { this.clearHighlight(); this.row = clamp(this.row + dr, 0, this.rows.length - 1); const cellCount = this.rows[this.row]?.cells.length ?? 1; this.col = clamp(this.col + dc, 0, cellCount - 1); this.highlight(); this.scrollIntoView(); } private goTop(): void { this.clearHighlight(); this.row = 0; this.highlight(); this.scrollIntoView(); } private goBottom(): void { this.clearHighlight(); this.row = this.rows.length - 1; this.highlight(); this.scrollIntoView(); } // --------------------------------------------------------------------------- // Highlight // --------------------------------------------------------------------------- private highlight(): void { const tr = this.rows[this.row]; if (!tr) return; tr.classList.add('logfire-vim-row'); const td = tr.cells[this.col]; if (td) td.classList.add('logfire-vim-cell'); } private clearHighlight(): void { for (const tr of this.rows) { tr.classList.remove('logfire-vim-row'); for (const td of Array.from(tr.cells)) { td.classList.remove('logfire-vim-cell'); } } } private scrollIntoView(): void { this.rows[this.row]?.scrollIntoView({ block: 'nearest' }); } // --------------------------------------------------------------------------- // Yank // --------------------------------------------------------------------------- private yankCell(): void { const td = this.rows[this.row]?.cells[this.col]; if (!td) return; navigator.clipboard.writeText(td.textContent ?? ''); td.classList.add('logfire-vim-yanked'); setTimeout(() => td.classList.remove('logfire-vim-yanked'), 600); new Notice('In Zwischenablage kopiert.'); } // --------------------------------------------------------------------------- // Open file // --------------------------------------------------------------------------- private openFile(): void { const td = this.rows[this.row]?.cells[this.col]; if (!td) return; const text = td.textContent?.trim() ?? ''; if (text.endsWith('.md') || text.includes('/')) { this.app.workspace.openLinkText(text, ''); } } // --------------------------------------------------------------------------- // Search // --------------------------------------------------------------------------- private openSearch(): void { if (!this.container) return; this.searchMode = true; this.searchQuery = ''; this.searchOverlay = this.container.createDiv({ cls: 'logfire-vim-search' }); this.searchOverlay.createEl('span', { text: '/' }); this.searchInput = this.searchOverlay.createEl('input', { attr: { type: 'text', placeholder: 'Suche...' }, }); this.searchInput.focus(); } private handleSearchKey(e: KeyboardEvent): void { if (e.key === 'Escape') { this.closeSearch(); e.preventDefault(); return; } if (e.key === 'Enter') { this.searchQuery = this.searchInput?.value ?? ''; this.closeSearch(); this.findNext(); e.preventDefault(); return; } // Let normal typing pass through to the input } private closeSearch(): void { this.searchOverlay?.remove(); this.searchOverlay = null; this.searchInput = null; this.searchMode = false; this.container?.focus(); } private findNext(): void { if (!this.searchQuery) return; const q = this.searchQuery.toLowerCase(); for (let i = 1; i <= this.rows.length; i++) { const idx = (this.row + i) % this.rows.length; const text = this.rows[idx].textContent?.toLowerCase() ?? ''; if (text.includes(q)) { this.clearHighlight(); this.row = idx; this.highlight(); this.scrollIntoView(); return; } } new Notice('Nicht gefunden.'); } } function clamp(val: number, min: number, max: number): number { return Math.max(min, Math.min(max, val)); }