- 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>
234 lines
6.7 KiB
TypeScript
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));
|
|
}
|