import { Component, Input, Output, EventEmitter, signal, ChangeDetectionStrategy, ElementRef, ViewChild, HostListener, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { SearchHistoryService } from '../../core/search/search-history.service'; import { SearchAssistantService, SearchOption } from '../../core/search/search-assistant.service'; type NavigationItem = | { type: 'option'; index: number; option: SearchOption } | { type: 'suggestion'; index: number; value: string } | { type: 'history'; index: number; value: string }; /** * Search query assistant component * Provides popover with search options, suggestions, and history * Based on Obsidian's search interface */ @Component({ selector: 'app-search-query-assistant', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@if (isOpen()) {

Search options

@if (showHelp) {
Operators:
OR Either term
-term Exclude
"phrase" Exact match
term* Wildcard
Combine: tag:#todo OR tag:#urgent
}
@if (history().length > 0) {

History

} @if (suggestions().length > 0) {

{{ suggestionsTitle() }}

}
}
`, styles: [` :host { display: block; position: relative; } code { font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; } /* Custom scrollbar */ ::-webkit-scrollbar { width: 8px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.2); border-radius: 4px; } :host-context(.dark) ::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); } `] }) export class SearchQueryAssistantComponent { @Input() context: string = 'default'; // Search context (vault, graph, etc.) @Input() currentQuery: string = ''; @Input() anchorElement: HTMLElement | null = null; @Input() showExamples: boolean = true; @Output() queryChange = new EventEmitter(); @Output() querySubmit = new EventEmitter(); @Output() previewChange = new EventEmitter(); @ViewChild('popover') popoverRef?: ElementRef; isOpen = signal(false); showHelp = false; selectedIndex = signal(-1); navigationItems = signal([]); history = signal([]); private historyService = inject(SearchHistoryService); private assistantService = inject(SearchAssistantService); private elementRef = inject(ElementRef); searchOptions = signal([]); // Keep track of the original query when starting keyboard navigation previews private originalQueryForPreview: string | null = null; /** * Open the assistant popover */ open(): void { this.isOpen.set(true); this.refreshHistory(); this.updateOptions(); this.updateSuggestions(); } /** * Close the assistant popover */ close(): void { this.isOpen.set(false); this.selectedIndex.set(-1); this.navigationItems.set([]); // Reset any preview state on close this.originalQueryForPreview = null; } /** * Toggle popover */ toggle(): void { if (this.isOpen()) { this.close(); } else { this.open(); } } /** * History items */ /** * Dynamic suggestions based on current query */ suggestions = signal([]); /** * Title for suggestions section */ suggestionsTitle = signal('Suggestions'); /** * Update search options based on current query */ updateOptions(): void { const filtered = this.assistantService.getFilteredOptions(this.currentQuery); this.searchOptions.set(filtered); this.refreshNavigation(); } /** * Update suggestions based on current query */ updateSuggestions(): void { const result = this.assistantService.getSuggestions(this.currentQuery); this.suggestions.set(result.items); this.suggestionsTitle.set(result.type); this.refreshNavigation(); } /** * Insert a search option prefix */ insertOption(prefix: string): void { const optionValue = prefix === '[property]' ? '[' : prefix; const { newQuery } = this.assistantService.insertOption( this.currentQuery, optionValue, this.currentQuery.length ); this.currentQuery = newQuery; this.queryChange.emit(newQuery); this.updateOptions(); this.updateSuggestions(); this.selectedIndex.set(-1); } /** * Insert a suggestion */ insertSuggestion(suggestion: string): void { const newQuery = this.assistantService.insertSuggestion(this.currentQuery, suggestion); this.currentQuery = newQuery; this.queryChange.emit(newQuery); this.close(); } /** * Select a history item */ selectHistoryItem(item: string): void { this.currentQuery = item; this.queryChange.emit(item); this.close(); } /** * Clear history */ clearHistory(): void { this.historyService.clear(this.context); this.refreshHistory(); } /** * Handle keyboard navigation */ @HostListener('document:keydown', ['$event']) handleKeyboard(event: KeyboardEvent): void { if (!this.isOpen()) return; const items = this.navigationItems(); if (items.length === 0) { if (event.key === 'Enter') { this.querySubmit.emit(this.currentQuery); this.close(); event.preventDefault(); event.stopPropagation(); } return; } if (event.key === 'ArrowDown') { event.preventDefault(); event.stopPropagation(); const next = this.selectedIndex() + 1; this.selectedIndex.set(next >= items.length ? 0 : next); this.previewSelectedItem(); } else if (event.key === 'ArrowUp') { event.preventDefault(); event.stopPropagation(); const prev = this.selectedIndex() - 1; this.selectedIndex.set(prev < 0 ? items.length - 1 : prev); this.previewSelectedItem(); } else if (event.key === 'Enter' && this.selectedIndex() >= 0) { event.preventDefault(); event.stopPropagation(); // Commit the selection and clear preview baseline const applied = this.applySelectedItem(); if (applied) { this.originalQueryForPreview = null; } } else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); // Revert any previewed value if (this.originalQueryForPreview !== null) { this.currentQuery = this.originalQueryForPreview; this.queryChange.emit(this.currentQuery); } this.close(); } } /** * Close on outside click */ @HostListener('document:click', ['$event']) handleOutsideClick(event: MouseEvent): void { if (!this.isOpen()) return; const target = event.target as HTMLElement; const insideAssistant = this.elementRef.nativeElement.contains(target); const insideAnchor = this.anchorElement ? this.anchorElement.contains(target) : false; if (!insideAssistant && !insideAnchor) { this.close(); } } private refreshNavigation(): void { if (!this.isOpen()) { this.navigationItems.set([]); this.selectedIndex.set(-1); this.originalQueryForPreview = null; return; } const items: NavigationItem[] = []; const options = this.searchOptions(); options.forEach((option, index) => { items.push({ type: 'option', index, option }); }); const historyItems = this.history(); historyItems.forEach((value, index) => { items.push({ type: 'history', index, value }); }); const suggestions = this.suggestions(); suggestions.forEach((value, index) => { items.push({ type: 'suggestion', index, value }); }); this.navigationItems.set(items); if (items.length === 0) { this.selectedIndex.set(-1); } else if (this.selectedIndex() >= items.length) { this.selectedIndex.set(items.length - 1); } } private refreshHistory(): void { const historyItems = this.historyService.list(this.context); this.history.set(historyItems); this.refreshNavigation(); } /** * Public method to refresh history from parent component */ refreshHistoryView(): void { this.refreshHistory(); } applySelectedItem(): boolean { if (!this.isOpen()) return false; const index = this.selectedIndex(); if (index < 0) return false; const items = this.navigationItems(); const selected = items[index]; if (!selected) return false; if (selected.type === 'suggestion') { this.insertSuggestion(selected.value); return true; } if (selected.type === 'option') { this.insertOption(selected.option.prefix); return true; } if (selected.type === 'history') { this.selectHistoryItem(selected.value); return true; } return false; } /** * Preview the currently selected item into the input without committing. * Restores original value on Escape or when closing. */ private previewSelectedItem(): void { const index = this.selectedIndex(); const items = this.navigationItems(); if (index < 0 || index >= items.length) { return; } // Capture the baseline only once at the start of navigation if (this.originalQueryForPreview === null) { this.originalQueryForPreview = this.currentQuery; } const selected = items[index]; let previewQuery = this.originalQueryForPreview; if (selected.type === 'suggestion') { previewQuery = this.assistantService.insertSuggestion(this.originalQueryForPreview!, selected.value); } else if (selected.type === 'option') { const optionValue = selected.option.prefix === '[property]' ? '[' : selected.option.prefix; const { newQuery } = this.assistantService.insertOption( this.originalQueryForPreview!, optionValue, this.originalQueryForPreview!.length ); previewQuery = newQuery; } else if (selected.type === 'history') { previewQuery = selected.value; } this.currentQuery = previewQuery ?? this.currentQuery; // Emit preview change instead of confirmed change to avoid recomputing options in parent this.previewChange.emit(this.currentQuery); } isSelected(type: NavigationItem['type'], index: number): boolean { const selected = this.navigationItems()[this.selectedIndex()]; if (!selected) return false; return selected.type === type && selected.index === index; } }