From 5e2b8a44dde1f80919b32eeba94f1a6eb52ac17e Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Fri, 26 Mar 2021 20:57:13 +0100 Subject: [PATCH] Improve Search field --- src/frontend/app/app.module.ts | 2 + ...andom-query-builder.gallery.component.html | 15 +- .../query-entry.search.gallery.component.ts | 14 +- .../search-field.gallery.component.css | 49 ++++ .../search-field.gallery.component.html | 58 ++++ .../search-field.gallery.component.ts | 254 ++++++++++++++++++ .../search/search.gallery.component.css | 63 +---- .../search/search.gallery.component.html | 72 +---- .../search/search.gallery.component.ts | 161 +---------- 9 files changed, 399 insertions(+), 289 deletions(-) create mode 100644 src/frontend/app/ui/gallery/search/search-field/search-field.gallery.component.css create mode 100644 src/frontend/app/ui/gallery/search/search-field/search-field.gallery.component.html create mode 100644 src/frontend/app/ui/gallery/search/search-field/search-field.gallery.component.ts diff --git a/src/frontend/app/app.module.ts b/src/frontend/app/app.module.ts index 35045f85..e80b7adb 100644 --- a/src/frontend/app/app.module.ts +++ b/src/frontend/app/app.module.ts @@ -97,6 +97,7 @@ import {GallerySearchQueryEntryComponent} from './ui/gallery/search/query-enrty/ import {StringifySearchQuery} from './pipes/StringifySearchQuery'; import {AutoCompleteService} from './ui/gallery/search/autocomplete.service'; import {SearchQueryParserService} from './ui/gallery/search/search-query-parser.service'; +import {GallerySearchFieldComponent} from './ui/gallery/search/search-field/search-field.gallery.component'; @Injectable() @@ -179,6 +180,7 @@ export function translationsFactory(locale: string) { FrameComponent, GallerySearchComponent, GallerySearchQueryEntryComponent, + GallerySearchFieldComponent, GalleryShareComponent, GalleryNavigatorComponent, GalleryPhotoComponent, diff --git a/src/frontend/app/ui/gallery/random-query-builder/random-query-builder.gallery.component.html b/src/frontend/app/ui/gallery/random-query-builder/random-query-builder.gallery.component.html index 501bad68..53b09728 100644 --- a/src/frontend/app/ui/gallery/random-query-builder/random-query-builder.gallery.component.html +++ b/src/frontend/app/ui/gallery/random-query-builder/random-query-builder.gallery.component.html @@ -32,16 +32,11 @@
- + + + void): void { @@ -147,7 +141,7 @@ export class GallerySearchQueryEntryComponent implements ControlValueAccessor, V this.propagateTouch = fn; } - public onChange(event: any) { + public onChange() { this.propagateChange(this.queryEntry); } diff --git a/src/frontend/app/ui/gallery/search/search-field/search-field.gallery.component.css b/src/frontend/app/ui/gallery/search/search-field/search-field.gallery.component.css new file mode 100644 index 00000000..3bb6bd93 --- /dev/null +++ b/src/frontend/app/ui/gallery/search/search-field/search-field.gallery.component.css @@ -0,0 +1,49 @@ +.autocomplete-list { + position: absolute; + left: 0; + top: 34px; + background-color: white; + width: 100%; + border: 1px solid #ccc; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + padding: 5px 0; + -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, .175); + box-shadow: 0 6px 12px rgba(0, 0, 0, .175); + z-index: 7; +} + +.autocomplete-item { + cursor: pointer; + padding-top: 2px; + padding-bottom: 2px; + font-size: 17px; +} + +.autocomplete-item { + color: #333; + padding: 0 20px; + line-height: 1.42857143; + font-weight: 400; + display: block; +} + +.autocomplete-item-selected { + background-color: #007bff; + color: #FFF; +} + + +.search-text { + z-index: 6; + width: 100%; + background: transparent; +} + +.search-hint { + z-index: 1; + width: 100%; + position: absolute; + margin-left: 0 !important; +} + diff --git a/src/frontend/app/ui/gallery/search/search-field/search-field.gallery.component.html b/src/frontend/app/ui/gallery/search/search-field/search-field.gallery.component.html new file mode 100644 index 00000000..471eca29 --- /dev/null +++ b/src/frontend/app/ui/gallery/search/search-field/search-field.gallery.component.html @@ -0,0 +1,58 @@ +
+ + + + + +
+
+
+ + + + + + + + + {{item.preText}}{{item.highLightText}}{{item.postText}} +
+
+
+ +
+ + diff --git a/src/frontend/app/ui/gallery/search/search-field/search-field.gallery.component.ts b/src/frontend/app/ui/gallery/search/search-field/search-field.gallery.component.ts new file mode 100644 index 00000000..ef6e4366 --- /dev/null +++ b/src/frontend/app/ui/gallery/search/search-field/search-field.gallery.component.ts @@ -0,0 +1,254 @@ +import {Component, ElementRef, EventEmitter, forwardRef, OnDestroy, Output, ViewChild} from '@angular/core'; +import {ActivatedRoute, Router, RouterLink} from '@angular/router'; +import {BehaviorSubject, Subscription} from 'rxjs'; +import {AutoCompleteService, RenderableAutoCompleteItem} from '../autocomplete.service'; +import {MetadataSearchQueryTypes, SearchQueryTypes} from '../../../../../../common/entities/SearchQueryDTO'; +import {SearchQueryParserService} from '../search-query-parser.service'; +import {GalleryService} from '../../gallery.service'; +import {NavigationService} from '../../../../model/navigation.service'; +import {Config} from '../../../../../../common/config/public/Config'; +import {ControlValueAccessor, FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator} from '@angular/forms'; + +@Component({ + selector: 'app-gallery-search-field', + templateUrl: './search-field.gallery.component.html', + styleUrls: ['./search-field.gallery.component.css'], + providers: [ + AutoCompleteService, RouterLink, + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => GallerySearchFieldComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => GallerySearchFieldComponent), + multi: true + } + ] +}) +export class GallerySearchFieldComponent implements ControlValueAccessor, Validator, OnDestroy { + + @ViewChild('SearchField', {static: false}) searchField: ElementRef; + @ViewChild('SearchHintField', {static: false}) searchHintField: ElementRef; + @Output() search = new EventEmitter(); + + autoCompleteRenders: AutoCompleteRenderItem[] = []; + public rawSearchText = ''; + mouseOverAutoComplete = false; + readonly SearchQueryTypes: typeof SearchQueryTypes; + public readonly MetadataSearchQueryTypes: { value: string; key: SearchQueryTypes }[]; + public highlightedAutoCompleteItem = 0; + private cache = { + lastAutocomplete: '', + lastInstantSearch: '' + }; + private autoCompleteItemsSubscription: Subscription = null; + private autoCompleteItems: BehaviorSubject; + + constructor(private _autoCompleteService: AutoCompleteService, + private _searchQueryParserService: SearchQueryParserService, + private _galleryService: GalleryService, + private navigationService: NavigationService, + private _route: ActivatedRoute, + public router: Router) { + + this.SearchQueryTypes = SearchQueryTypes; + this.MetadataSearchQueryTypes = MetadataSearchQueryTypes.map(v => ({key: v, value: SearchQueryTypes[v]})); + + } + + get SearchHint() { + if (!this.autoCompleteItems || + !this.autoCompleteItems.value || this.autoCompleteItems.value.length === 0) { + return this.rawSearchText; + } + const searchText = this.getAutocompleteToken(); + if (searchText.current === '') { + return this.rawSearchText + this.autoCompleteItems.value[this.highlightedAutoCompleteItem].queryHint; + } + 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; + } + + + ngOnDestroy() { + if (this.autoCompleteItemsSubscription) { + this.autoCompleteItemsSubscription.unsubscribe(); + this.autoCompleteItemsSubscription = null; + } + } + + getAutocompleteToken(): { current: string, prev: string } { + if (this.rawSearchText.trim().length === 0) { + return {current: '', prev: ''}; + } + const tokens = this.rawSearchText.split(' '); + 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.current) { + this.cache.lastAutocomplete = searchText.current; + this.autocomplete(searchText).catch(console.error); + } + + } + + public setMouseOverAutoComplete(value: boolean) { + this.mouseOverAutoComplete = value; + } + + public onFocusLost() { + if (this.mouseOverAutoComplete === false) { + this.autoCompleteRenders = []; + } + } + + public onFocus() { + // TODO: implement autocomplete + // this.autocomplete(this.searchText).catch(console.error); + } + + + applyHint($event: any) { + if ($event.target.selectionStart !== this.rawSearchText.length) { + return; + } + this.rawSearchText = this.SearchHint; + this.onChange(); + } + + applyAutoComplete(item: AutoCompleteRenderItem) { + const token = this.getAutocompleteToken(); + this.rawSearchText = this.rawSearchText.substr(0, this.rawSearchText.length - token.current.length) + + item.queryHint; + this.onChange(); + this.emptyAutoComplete(); + } + + 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.emit(); + return; + } + this.applyAutoComplete(this.autoCompleteRenders[this.highlightedAutoCompleteItem]); + } + + public onTouched(): void { + } + + public writeValue(obj: any): void { + this.rawSearchText = obj; + } + + registerOnChange(fn: (_: any) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.propagateTouch = fn; + } + + public onChange() { + this.propagateChange(this.rawSearchText); + } + + validate(control: FormControl): ValidationErrors { + return {required: true}; + } + + Scrolled() { + this.searchHintField.nativeElement.scrollLeft = this.searchField.nativeElement.scrollLeft; + } + + private emptyAutoComplete() { + this.highlightedAutoCompleteItem = 0; + this.autoCompleteRenders = []; + } + + private async autocomplete(searchText: { current: string, prev: string }) { + if (!Config.Client.Search.AutoComplete.enabled) { + return; + } + + 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.autoCompleteItemsSubscription = this.autoCompleteItems.subscribe(() => { + this.showSuggestions(this.autoCompleteItems.value, searchText.current); + }); + } catch (error) { + console.error(error); + } + + } else { + this.emptyAutoComplete(); + } + } + + private showSuggestions(suggestions: RenderableAutoCompleteItem[], searchText: string) { + this.emptyAutoComplete(); + suggestions.forEach((item: RenderableAutoCompleteItem) => { + const renderItem = new AutoCompleteRenderItem(item.text, searchText, item.type, item.queryHint); + this.autoCompleteRenders.push(renderItem); + }); + } + + private propagateChange = (_: any) => { + }; + + private propagateTouch = (_: any) => { + }; +} + +class AutoCompleteRenderItem { + public preText = ''; + public highLightText = ''; + public postText = ''; + public type: SearchQueryTypes; + public queryHint: string; + + constructor(public text: string, searchText: string, type: SearchQueryTypes, queryHint: string) { + const preIndex = text.toLowerCase().indexOf(searchText.toLowerCase()); + if (preIndex > -1) { + this.preText = text.substring(0, preIndex); + this.highLightText = text.substring(preIndex, preIndex + searchText.length); + this.postText = text.substring(preIndex + searchText.length); + } else { + this.postText = text; + } + this.type = type; + this.queryHint = queryHint; + } +} + 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 75e43dce..3a0c7ed1 100644 --- a/src/frontend/app/ui/gallery/search/search.gallery.component.css +++ b/src/frontend/app/ui/gallery/search/search.gallery.component.css @@ -1,54 +1,15 @@ -.autocomplete-list { - position: absolute; - left: 0; - top: 34px; - background-color: white; - width: 100%; - border: 1px solid #ccc; - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; - padding: 5px 0; - -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, .175); - box-shadow: 0 6px 12px rgba(0, 0, 0, .175); - z-index: 7; -} - -.autocomplete-item { - cursor: pointer; - padding-top: 2px; - padding-bottom: 2px; - font-size: 17px; -} - -.autocomplete-item { - color: #333; - padding: 0 20px; - line-height: 1.42857143; - font-weight: 400; - display: block; -} - -.autocomplete-item-selected { - background-color: #007bff; - color: #FFF; -} - - -.search-text { - border: 0; - z-index: 6; - width: 500px; - background: transparent; -} - -.search-hint { - width: 500px; - background: white; - z-index: 1; - position: absolute; - border: 0; -} - form { padding-right: 1em; } + +@media screen and ( min-width: 1200px) { + .search-field { + width: 400px; + } +} + +@media screen and ( min-width: 1400px) { + .search-field { + width: 500px; + } +} 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 09c5c1c2..0abfb650 100644 --- a/src/frontend/app/ui/gallery/search/search.gallery.component.html +++ b/src/frontend/app/ui/gallery/search/search.gallery.component.html @@ -1,57 +1,13 @@
- - + -
-
-
- - - - - - - - - {{item.preText}}{{item.highLightText}}{{item.postText}} -
-
-
+