obsidian-logfire/src/viz/keyboard-nav.ts
tolvitty c2b7918ce4 Feature 9: Polish & Extras — Vim-Navigation, Autocomplete, Export, erweiterte Settings
- keyboard-nav.ts: Vim-Navigation fuer Tabellen (j/k/h/l, gg/G, /, y, Enter, Esc)
- autocomplete.ts: SQL-Autocomplete mit Kontext-Erkennung (Tabellen, Spalten, Keywords, Funktionen, Event-Typen)
- query-modal.ts: CSV/JSON-Export-Buttons, Autocomplete-Integration (Dropdown, Arrow-Nav, Tab/Enter Accept)
- settings-tab.ts: Erweiterte DB-Settings (WAL-Toggle, Cache-Slider, MMap-Slider), geschuetzte Event-Typen, Info-Sektion
- main.ts: SqlAutocomplete + KeyboardNavigator Integration, 3 neue Commands (export-csv, export-json, toggle-vim-nav)
- styles.css: Vim-Navigation (.logfire-vim-*), Autocomplete-Dropdown (.logfire-ac-*), Info-Sektion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 11:55:44 +01:00

234 lines
6.7 KiB
TypeScript

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<HTMLTableElement>('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));
}