ObsiViewer/src/components/search-query-assistant/search-query-assistant.component.ts

498 lines
16 KiB
TypeScript

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: `
<div class="relative">
<!-- Popover -->
@if (isOpen()) {
<div
#popover
class="absolute top-full left-0 right-0 mt-1 bg-bg-primary dark:bg-gray-800 border border-border dark:border-gray-700 rounded-xl shadow-2xl z-50 overflow-hidden max-h-[360px]"
style="z-index: 9999;"
(click)="$event.stopPropagation()"
>
<!-- Search Options -->
<div class="p-3 border-b border-border dark:border-gray-700">
<div class="flex items-center justify-between mb-2">
<h4 class="text-xs font-semibold uppercase tracking-wide text-text-main dark:text-gray-100">Search options</h4>
<button
(click)="showHelp = !showHelp"
class="p-1 rounded-full hover:bg-bg-muted dark:hover:bg-gray-700 transition-colors"
title="Show help"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
@if (showHelp) {
<div class="mb-2 p-2 bg-bg-muted dark:bg-gray-900 rounded-lg text-[11px] space-y-1.5">
<div><strong>Operators:</strong></div>
<div class="grid grid-cols-2 gap-1.5 text-text-muted dark:text-gray-400">
<div><code class="px-1 py-0.5 bg-bg-primary dark:bg-gray-800 rounded">OR</code> Either term</div>
<div><code class="px-1 py-0.5 bg-bg-primary dark:bg-gray-800 rounded">-term</code> Exclude</div>
<div><code class="px-1 py-0.5 bg-bg-primary dark:bg-gray-800 rounded">"phrase"</code> Exact match</div>
<div><code class="px-1 py-0.5 bg-bg-primary dark:bg-gray-800 rounded">term*</code> Wildcard</div>
</div>
<div><strong>Combine:</strong> <code class="px-1 py-0.5 bg-bg-primary dark:bg-gray-800 rounded">tag:#todo OR tag:#urgent</code></div>
</div>
}
<div class="space-y-1 max-h-[200px] overflow-y-auto transition-all pr-1">
<button
*ngFor="let option of searchOptions(); let i = index"
(click)="insertOption(option.prefix)"
class="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-bg-muted dark:hover:bg-gray-700 transition-colors group text-sm"
[ngClass]="{
'bg-bg-muted': isSelected('option', i),
'dark:bg-gray-700': isSelected('option', i)
}"
>
<div class="flex items-center gap-2">
<code class="text-xs font-mono text-accent dark:text-blue-400 whitespace-nowrap">{{ option.prefix }}</code>
<div class="flex-1 min-w-0 text-[13px] text-text-muted dark:text-gray-400 group-hover:text-text-main dark:group-hover:text-gray-100">
{{ option.description }}
</div>
<span
*ngIf="showExamples && option.example"
class="text-[11px] text-text-muted dark:text-gray-500 font-mono ml-2 truncate"
>
{{ option.example }}
</span>
</div>
</button>
</div>
</div>
<!-- History -->
@if (history().length > 0) {
<div class="p-3 border-b border-border dark:border-gray-700">
<div class="flex items-center justify-between mb-2">
<h4 class="text-xs font-semibold uppercase tracking-wide text-text-main dark:text-gray-100">History</h4>
<button
(click)="clearHistory()"
class="text-[11px] text-text-muted dark:text-gray-400 hover:text-accent dark:hover:text-blue-400 transition-colors"
>
Clear
</button>
</div>
<div class="space-y-1 max-h-[150px] overflow-y-auto pr-1">
<button
*ngFor="let item of history(); let i = index"
(click)="selectHistoryItem(item)"
class="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-bg-muted dark:hover:bg-gray-700 transition-colors group text-sm"
[ngClass]="{
'bg-bg-muted': isSelected('history', i),
'dark:bg-gray-700': isSelected('history', i)
}"
>
<span class="block truncate text-[13px] text-text-muted dark:text-gray-300 group-hover:text-text-main dark:group-hover:text-gray-100 font-mono">
{{ item }}
</span>
</button>
</div>
</div>
}
<!-- Dynamic Suggestions -->
@if (suggestions().length > 0) {
<div class="p-3">
<h4 class="text-xs font-semibold uppercase tracking-wide text-text-main dark:text-gray-100 mb-2">
{{ suggestionsTitle() }}
</h4>
<div class="space-y-1 max-h-[140px] overflow-y-auto pr-1">
<button
*ngFor="let suggestion of suggestions(); let i = index"
(click)="insertSuggestion(suggestion)"
class="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-bg-muted dark:hover:bg-gray-700 transition-colors text-sm"
[ngClass]="{
'bg-bg-muted': isSelected('suggestion', i),
'dark:bg-gray-700': isSelected('suggestion', i)
}"
>
<span class="block truncate text-[13px] text-text-main dark:text-gray-200">{{ suggestion }}</span>
</button>
</div>
</div>
}
</div>
}
</div>
`,
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<string>();
@Output() querySubmit = new EventEmitter<string>();
@Output() previewChange = new EventEmitter<string>();
@ViewChild('popover') popoverRef?: ElementRef;
isOpen = signal(false);
showHelp = false;
selectedIndex = signal(-1);
navigationItems = signal<NavigationItem[]>([]);
history = signal<string[]>([]);
private historyService = inject(SearchHistoryService);
private assistantService = inject(SearchAssistantService);
private elementRef = inject(ElementRef);
searchOptions = signal<SearchOption[]>([]);
// 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<string[]>([]);
/**
* Title for suggestions section
*/
suggestionsTitle = signal<string>('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;
}
}