diff --git a/src/frontend/app/ui/gallery/cache.gallery.service.ts b/src/frontend/app/ui/gallery/cache.gallery.service.ts index ef73a5d1..ae0a7e11 100644 --- a/src/frontend/app/ui/gallery/cache.gallery.service.ts +++ b/src/frontend/app/ui/gallery/cache.gallery.service.ts @@ -2,12 +2,12 @@ import {Injectable} from '@angular/core'; import {DirectoryDTO} from '../../../../common/entities/DirectoryDTO'; import {Utils} from '../../../../common/Utils'; import {Config} from '../../../../common/config/public/Config'; -import {AutoCompleteItem, IAutoCompleteItem} from '../../../../common/entities/AutoCompleteItem'; +import {IAutoCompleteItem} from '../../../../common/entities/AutoCompleteItem'; import {SearchResultDTO} from '../../../../common/entities/SearchResultDTO'; import {MediaDTO} from '../../../../common/entities/MediaDTO'; import {SortingMethods} from '../../../../common/entities/SortingMethods'; import {VersionService} from '../../model/version.service'; -import {SearchQueryDTO} from '../../../../common/entities/SearchQueryDTO'; +import {SearchQueryDTO, SearchQueryTypes} from '../../../../common/entities/SearchQueryDTO'; interface CacheItem { timestamp: number; @@ -101,11 +101,11 @@ export class GalleryCacheService { return null; } - public getAutoComplete(text: string): IAutoCompleteItem[] { + public getAutoComplete(text: string, type: SearchQueryTypes): IAutoCompleteItem[] { if (Config.Client.Other.enableCache === false) { return null; } - const key = GalleryCacheService.AUTO_COMPLETE_PREFIX + text; + const key = GalleryCacheService.AUTO_COMPLETE_PREFIX + text + (type ? '_' + type : ''); const tmp = localStorage.getItem(key); if (tmp != null) { const value: CacheItem = JSON.parse(tmp); @@ -118,16 +118,17 @@ export class GalleryCacheService { return null; } - public setAutoComplete(text: string, items: Array): void { + public setAutoComplete(text: string, type: SearchQueryTypes, items: Array): void { if (Config.Client.Other.enableCache === false) { return; } + const key = GalleryCacheService.AUTO_COMPLETE_PREFIX + text + (type ? '_' + type : ''); const tmp: CacheItem> = { timestamp: Date.now(), item: items }; try { - localStorage.setItem(GalleryCacheService.AUTO_COMPLETE_PREFIX + text, JSON.stringify(tmp)); + localStorage.setItem(key, JSON.stringify(tmp)); } catch (e) { this.reset(); console.error(e); diff --git a/src/frontend/app/ui/gallery/search/autocomplete.service.ts b/src/frontend/app/ui/gallery/search/autocomplete.service.ts index 9f9021ff..1b74c970 100644 --- a/src/frontend/app/ui/gallery/search/autocomplete.service.ts +++ b/src/frontend/app/ui/gallery/search/autocomplete.service.ts @@ -5,11 +5,14 @@ import {GalleryCacheService} from '../cache.gallery.service'; import {SearchQueryParserService} from './search-query-parser.service'; import {BehaviorSubject} from 'rxjs'; import {SearchQueryTypes, TextSearchQueryTypes} from '../../../../../common/entities/SearchQueryDTO'; +import {QueryParams} from '../../../../../common/QueryParams'; @Injectable() export class AutoCompleteService { private keywords: string[] = []; + private relationKeywords: string[] = []; + private textSearchKeywordsMap: { [key: string]: SearchQueryTypes } = {}; constructor(private _networkService: NetworkService, private _searchQueryParserService: SearchQueryParserService, @@ -27,15 +30,37 @@ export class AutoCompleteService { for (let i = 0; i < 10; i++) { this.keywords.push(i + this._searchQueryParserService.keywords.NSomeOf); } + + TextSearchQueryTypes.forEach(t => { + this.textSearchKeywordsMap[(this._searchQueryParserService.keywords)[SearchQueryTypes[t]]] = t; + }); } - public autoComplete(text: string): BehaviorSubject { + public autoComplete(text: { current: string, prev: string }): BehaviorSubject { const items: BehaviorSubject = new BehaviorSubject( - this.sortResults(text, this.getQueryKeywords(text))); - const cached = this._galleryCacheService.getAutoComplete(text); + this.sortResults(text.current, this.getQueryKeywords(text))); + + const type = this.getTypeFromPrefix(text.current); + const searchText = this.getPrefixLessSearchText(text.current); + if (searchText === '') { + return items; + } + this.typedAutoComplete(searchText, type, items); + return items; + } + + public typedAutoComplete(text: string, type: SearchQueryTypes, + items?: BehaviorSubject): BehaviorSubject { + items = items || new BehaviorSubject([]); + + const cached = this._galleryCacheService.getAutoComplete(text, type); if (cached == null) { - this._networkService.getJson('/autocomplete/' + text).then(ret => { - this._galleryCacheService.setAutoComplete(text, ret); + const acParams: any = {}; + if (type) { + acParams[QueryParams.gallery.search.type] = type; + } + this._networkService.getJson('/autocomplete/' + text, acParams).then(ret => { + this._galleryCacheService.setAutoComplete(text, type, ret); items.next(this.sortResults(text, ret.map(i => this.ACItemToRenderable(i)).concat(items.value))); }); } else { @@ -44,6 +69,22 @@ export class AutoCompleteService { return items; } + private getTypeFromPrefix(text: string): SearchQueryTypes { + const tokens = text.split(':'); + if (tokens.length !== 2) { + return null; + } + return this.textSearchKeywordsMap[tokens[0]] || null; + } + + private getPrefixLessSearchText(text: string): string { + const tokens = text.split(':'); + if (tokens.length !== 2) { + return text; + } + return tokens[1]; + } + private ACItemToRenderable(item: IAutoCompleteItem): RenderableAutoCompleteItem { if (!item.type) { return {text: item.text, queryHint: item.text}; @@ -52,7 +93,7 @@ export class AutoCompleteService { return { text: item.text, type: item.type, queryHint: - (this._searchQueryParserService.keywords)[SearchQueryTypes[item.type]] + ':(' + item.text + ')' + (this._searchQueryParserService.keywords)[SearchQueryTypes[item.type]] + ':"' + item.text + '"' }; } return { @@ -73,9 +114,20 @@ export class AutoCompleteService { } - private getQueryKeywords(text: string): RenderableAutoCompleteItem[] { + private getQueryKeywords(text: { current: string, prev: string }): RenderableAutoCompleteItem[] { + // if empty, recommend "and" + if (text.current === '') { + if (text.prev !== this._searchQueryParserService.keywords.and) { + return [{ + text: this._searchQueryParserService.keywords.and, + queryHint: this._searchQueryParserService.keywords.and + }]; + } else { + return []; + } + } return this.keywords - .filter(key => key.startsWith(text)) + .filter(key => key.startsWith(text.current)) .map(key => ({ text: key, queryHint: key diff --git a/src/frontend/app/ui/gallery/search/search.gallery.component.css b/src/frontend/app/ui/gallery/search/search.gallery.component.css index fd9966f7..75e43dce 100644 --- a/src/frontend/app/ui/gallery/search/search.gallery.component.css +++ b/src/frontend/app/ui/gallery/search/search.gallery.component.css @@ -20,7 +20,7 @@ font-size: 17px; } -.autocomplete-item a { +.autocomplete-item { color: #333; padding: 0 20px; line-height: 1.42857143; @@ -28,14 +28,11 @@ display: block; } -.autocomplete-item:hover { +.autocomplete-item-selected { background-color: #007bff; + color: #FFF; } -.autocomplete-item:hover a { - color: #FFF; - text-decoration: none; -} .search-text { border: 0; diff --git a/src/frontend/app/ui/gallery/search/search.gallery.component.html b/src/frontend/app/ui/gallery/search/search.gallery.component.html index ec56bb38..09c5c1c2 100644 --- a/src/frontend/app/ui/gallery/search/search.gallery.component.html +++ b/src/frontend/app/ui/gallery/search/search.gallery.component.html @@ -9,8 +9,10 @@ (focus)="onFocus()" [(ngModel)]="rawSearchText" (ngModelChange)="validateRawSearchText()" - (keydown.enter)="Search()" + (keydown.enter)="OnEnter($event)" (keydown.arrowRight)="applyHint($event)" + (keydown.arrowUp)="selectAutocompleteUp()" + (keydown.arrowDown)="selectAutocompleteDown()" #name="ngModel" size="30" ngControl="search" @@ -32,7 +34,11 @@
-
+
diff --git a/src/frontend/app/ui/gallery/search/search.gallery.component.ts b/src/frontend/app/ui/gallery/search/search.gallery.component.ts index a2992fd5..4bdd6a09 100644 --- a/src/frontend/app/ui/gallery/search/search.gallery.component.ts +++ b/src/frontend/app/ui/gallery/search/search.gallery.component.ts @@ -26,6 +26,7 @@ export class GallerySearchComponent implements OnDestroy { readonly SearchQueryTypes: typeof SearchQueryTypes; modalRef: BsModalRef; public readonly MetadataSearchQueryTypes: { value: string; key: SearchQueryTypes }[]; + public highlightedAutoCompleteItem = 0; private cache = { lastAutocomplete: '', lastInstantSearch: '' @@ -62,11 +63,11 @@ export class GallerySearchComponent implements OnDestroy { return this.rawSearchText; } const searchText = this.getAutocompleteToken(); - if (searchText === '') { - return this.rawSearchText; + if (searchText.current === '') { + return this.rawSearchText + this.autoCompleteItems.value[this.highlightedAutoCompleteItem].queryHint; } - if (this.autoCompleteItems.value[0].queryHint.startsWith(searchText)) { - return this.rawSearchText + this.autoCompleteItems.value[0].queryHint.substr(searchText.length); + if (this.autoCompleteItems.value[0].queryHint.startsWith(searchText.current)) { + return this.rawSearchText + this.autoCompleteItems.value[this.highlightedAutoCompleteItem].queryHint.substr(searchText.current.length); } return this.rawSearchText; } @@ -82,20 +83,23 @@ export class GallerySearchComponent implements OnDestroy { } } - getAutocompleteToken(): string { + getAutocompleteToken(): { current: string, prev: string } { if (this.rawSearchText.trim().length === 0) { - return ''; + return {current: '', prev: ''}; } const tokens = this.rawSearchText.split(' '); - return tokens[tokens.length - 1]; + return { + current: tokens[tokens.length - 1], + prev: (tokens.length > 2 ? tokens[tokens.length - 2] : '') + }; } onSearchChange(event: KeyboardEvent) { const searchText = this.getAutocompleteToken(); if (Config.Client.Search.AutoComplete.enabled && - this.cache.lastAutocomplete !== searchText) { - this.cache.lastAutocomplete = searchText; + this.cache.lastAutocomplete !== searchText.current) { + this.cache.lastAutocomplete = searchText.current; this.autocomplete(searchText).catch(console.error); } @@ -157,35 +161,55 @@ export class GallerySearchComponent implements OnDestroy { applyAutoComplete(item: AutoCompleteRenderItem) { const token = this.getAutocompleteToken(); - this.rawSearchText = this.rawSearchText.substr(0, this.rawSearchText.length - token.length) + this.rawSearchText = this.rawSearchText.substr(0, this.rawSearchText.length - token.current.length) + item.queryHint; this.emptyAutoComplete(); this.validateRawSearchText(); } + setMouseOverAutoCompleteItem(i: number) { + this.highlightedAutoCompleteItem = i; + } + + selectAutocompleteUp() { + if (this.highlightedAutoCompleteItem > 0) { + this.highlightedAutoCompleteItem--; + } + } + + selectAutocompleteDown() { + if (this.autoCompleteItems && + this.highlightedAutoCompleteItem < this.autoCompleteItems.value.length - 1) { + this.highlightedAutoCompleteItem++; + } + } + + OnEnter($event: any) { + if (this.autoCompleteRenders.length === 0) { + this.Search(); + return; + } + this.applyAutoComplete(this.autoCompleteRenders[this.highlightedAutoCompleteItem]); + } + private emptyAutoComplete() { + this.highlightedAutoCompleteItem = 0; this.autoCompleteRenders = []; } - private async autocomplete(searchText: string) { + private async autocomplete(searchText: { current: string, prev: string }) { if (!Config.Client.Search.AutoComplete.enabled) { return; } - if (searchText.trim().length === 0 || - searchText.trim() === '.') { - return; - } - - - if (searchText.trim().length > 0) { + if (this.rawSearchText.trim().length > 0) { // are we searching for anything? try { if (this.autoCompleteItems) { this.autoCompleteItems.unsubscribe(); } this.autoCompleteItems = this._autoCompleteService.autoComplete(searchText); this.autoCompleteItems.subscribe(() => { - this.showSuggestions(this.autoCompleteItems.value, searchText); + this.showSuggestions(this.autoCompleteItems.value, searchText.current); }); } catch (error) { console.error(error);