498 lines
16 KiB
TypeScript
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;
|
|
}
|
|
}
|