feat(aio): add search service and search UI
This commit is contained in:
parent
71e22b8d11
commit
dca83ec738
|
@ -10,6 +10,7 @@
|
|||
"assets": [
|
||||
"assets",
|
||||
"content",
|
||||
"app/search/search-worker.js",
|
||||
"favicon.ico"
|
||||
],
|
||||
"index": "index.html",
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<button *ngIf="isHamburgerVisible" class="hamburger" md-button (click)="sidenav.toggle()"><md-icon>menu</md-icon></button>
|
||||
<aio-top-menu [nodes]="(navigationViews | async)?.TopBar"></aio-top-menu>
|
||||
<md-input-container >
|
||||
<input #search mdInput placeholder="Search">
|
||||
<input mdInput placeholder="Search" (keyup)="onSearch($event)">
|
||||
</md-input-container>
|
||||
<span class="fill-remaining-space"></span>
|
||||
</md-toolbar>
|
||||
|
@ -10,11 +10,15 @@
|
|||
<md-sidenav-container class="sidenav-container" (window:resize)="onResize($event.target.innerWidth)">
|
||||
|
||||
<md-sidenav #sidenav class="sidenav" [opened]="isSideBySide" [mode] = "this.isSideBySide ? 'side' : 'over'">
|
||||
|
||||
<aio-nav-menu [nodes]="(navigationViews | async)?.SideNav"></aio-nav-menu>
|
||||
</md-sidenav>
|
||||
|
||||
<section class="sidenav-content">
|
||||
<div class="search-results">
|
||||
<div *ngFor="let result of (searchResults | async)?.results">
|
||||
<a href="{{ result.path }}">{{ result.title }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<aio-doc-viewer [doc]="currentDocument | async"></aio-doc-viewer>
|
||||
</section>
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import { Component, ViewChild, OnInit } from '@angular/core';
|
|||
import { Observable } from 'rxjs/Observable';
|
||||
import { DocumentService, DocumentContents } from 'app/documents/document.service';
|
||||
import { NavigationService, NavigationViews } from 'app/navigation/navigation.service';
|
||||
import { SearchService, QueryResults } from 'app/search/search.service';
|
||||
|
||||
@Component({
|
||||
selector: 'aio-shell',
|
||||
|
@ -91,10 +92,12 @@ export class AppComponent implements OnInit {
|
|||
|
||||
currentDocument: Observable<DocumentContents>;
|
||||
navigationViews: Observable<NavigationViews>;
|
||||
searchResults: Observable<QueryResults>;
|
||||
|
||||
constructor(documentService: DocumentService, navigationService: NavigationService) {
|
||||
constructor(documentService: DocumentService, navigationService: NavigationService, private searchService: SearchService) {
|
||||
this.currentDocument = documentService.currentDocument;
|
||||
this.navigationViews = navigationService.navigationViews;
|
||||
this.searchResults = searchService.searchResults;
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
|
@ -104,4 +107,10 @@ export class AppComponent implements OnInit {
|
|||
onResize(width) {
|
||||
this.isSideBySide = width > this.sideBySideWidth;
|
||||
}
|
||||
|
||||
onSearch(event: KeyboardEvent) {
|
||||
const query = (event.target as HTMLInputElement).value;
|
||||
console.log(query);
|
||||
this.searchService.search(query);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ import { Logger } from 'app/shared/logger.service';
|
|||
import { LocationService } from 'app/shared/location.service';
|
||||
import { NavigationService } from 'app/navigation/navigation.service';
|
||||
import { DocumentService } from 'app/documents/document.service';
|
||||
import { SearchService } from 'app/search/search.service';
|
||||
import { TopMenuComponent } from 'app/layout/top-menu/top-menu.component';
|
||||
import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
|
||||
import { NavItemComponent } from 'app/layout/nav-item/nav-item.component';
|
||||
|
@ -54,6 +55,7 @@ import { LinkDirective } from 'app/shared/link.directive';
|
|||
LocationService,
|
||||
NavigationService,
|
||||
DocumentService,
|
||||
SearchService,
|
||||
Platform
|
||||
],
|
||||
entryComponents: [ embeddedComponents ],
|
||||
|
|
|
@ -1,111 +0,0 @@
|
|||
/*
|
||||
Copyright 2016 Google Inc. All Rights Reserved.
|
||||
Use of this source code is governed by an MIT-style license that
|
||||
can be found in the LICENSE file at http://angular.io/license
|
||||
*/
|
||||
|
||||
import {NgZone} from '@angular/core';
|
||||
import {Observable} from 'rxjs/Observable';
|
||||
import {Subscriber} from 'rxjs/Subscriber';
|
||||
import 'rxjs/add/observable/fromPromise';
|
||||
import 'rxjs/add/observable/of';
|
||||
import 'rxjs/add/operator/switchMap';
|
||||
|
||||
|
||||
/**
|
||||
* We will use this client from a component with something like...
|
||||
*
|
||||
* ngOnInit() {
|
||||
* const searchWorker = new SearchWorkerClient('app/search-worker.js', this.zone);
|
||||
* this.indexReady = searchWorker.ready;
|
||||
* this.searchInput = new FormControl();
|
||||
* this.searchResult$ = this.searchInput.valueChanges
|
||||
* .switchMap((searchText: string) => searchWorker.search(searchText));
|
||||
* }
|
||||
*
|
||||
* TODO(petebd): do we need a fallback for browsers that do not support service workers?
|
||||
*/
|
||||
|
||||
type QueryResults = Object[];
|
||||
|
||||
export interface ResultsReadyMessage {
|
||||
type: 'query-results';
|
||||
id: number;
|
||||
query: string;
|
||||
results: QueryResults;
|
||||
}
|
||||
|
||||
export class SearchWorkerClient {
|
||||
ready: Promise<boolean>;
|
||||
worker: Worker;
|
||||
private _queryId = 0;
|
||||
|
||||
constructor(url: string, private zone: NgZone) {
|
||||
this.worker = new Worker(url);
|
||||
this.ready = this._waitForIndex(this.worker);
|
||||
}
|
||||
|
||||
search(query: string) {
|
||||
return Observable.fromPromise(this.ready)
|
||||
.switchMap(() => this._createQuery(query));
|
||||
}
|
||||
|
||||
private _waitForIndex(worker: Worker) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
worker.onmessage = (e) => {
|
||||
if (e.data.type === 'index-ready') {
|
||||
resolve(true);
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (e) => {
|
||||
reject(e);
|
||||
cleanup();
|
||||
};
|
||||
});
|
||||
|
||||
function cleanup() {
|
||||
worker.onmessage = null;
|
||||
worker.onerror = null;
|
||||
}
|
||||
}
|
||||
|
||||
private _createQuery(query: string) {
|
||||
return new Observable<QueryResults>((subscriber: Subscriber<QueryResults>) => {
|
||||
|
||||
// get a new identifier for this query that we can match to results
|
||||
const id = this._queryId++;
|
||||
|
||||
const handleMessage = (message: MessageEvent) => {
|
||||
const {type, id: queryId, results} = message.data as ResultsReadyMessage;
|
||||
if (type === 'query-results' && id === queryId) {
|
||||
this.zone.run(() => {
|
||||
subscriber.next(results);
|
||||
subscriber.complete();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (error: ErrorEvent) => {
|
||||
this.zone.run(() => {
|
||||
subscriber.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
// Wire up the event listeners for this query
|
||||
this.worker.addEventListener('message', handleMessage);
|
||||
this.worker.addEventListener('error', handleError);
|
||||
|
||||
// Post the query to the web worker
|
||||
this.worker.postMessage({query, id});
|
||||
|
||||
// At completion/error unwire the event listeners
|
||||
return () => {
|
||||
this.worker.removeEventListener('message', handleMessage);
|
||||
this.worker.removeEventListener('error', handleError);
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
|
@ -3,13 +3,13 @@
|
|||
/* eslint-env worker */
|
||||
/* global importScripts, lunr */
|
||||
|
||||
var SEARCH_TERMS_URL = '/content/docs/app/search-data.json';
|
||||
|
||||
importScripts('https://unpkg.com/lunr@0.7.2');
|
||||
|
||||
var index = createIndex();
|
||||
var pages = {};
|
||||
|
||||
makeRequest('search-data.json', loadIndex);
|
||||
|
||||
self.onmessage = handleMessage;
|
||||
|
||||
// Create the lunr index - the docs should be an array of objects, each object containing
|
||||
|
@ -23,9 +23,38 @@ function createIndex() {
|
|||
});
|
||||
}
|
||||
|
||||
// The worker receives a message to load the index and to query the index
|
||||
function handleMessage(message) {
|
||||
var type = message.data.type;
|
||||
var id = message.data.id;
|
||||
var payload = message.data.payload;
|
||||
switch(type) {
|
||||
case 'load-index':
|
||||
makeRequest(SEARCH_TERMS_URL, function(searchInfo) {
|
||||
loadIndex(searchInfo);
|
||||
self.postMessage({type: type, id: id, payload: true});
|
||||
});
|
||||
break;
|
||||
case 'query-index':
|
||||
self.postMessage({type: type, id: id, payload: {query: payload, results: queryIndex(payload)}});
|
||||
break;
|
||||
default:
|
||||
self.postMessage({type: type, id: id, payload: {error: 'invalid message type'}})
|
||||
}
|
||||
}
|
||||
|
||||
// Use XHR to make a request to the server
|
||||
function makeRequest(url, callback) {
|
||||
|
||||
// The JSON file that is loaded should be an array of SearchTerms:
|
||||
//
|
||||
// export interface SearchTerms {
|
||||
// path: string;
|
||||
// type: string,
|
||||
// titleWords: string;
|
||||
// keyWords: string;
|
||||
// }
|
||||
|
||||
var searchDataRequest = new XMLHttpRequest();
|
||||
searchDataRequest.onload = function() {
|
||||
callback(JSON.parse(this.responseText));
|
||||
|
@ -43,19 +72,8 @@ function loadIndex(searchInfo) {
|
|||
index.add(page);
|
||||
pages[page.path] = page;
|
||||
});
|
||||
self.postMessage({type: 'index-ready'});
|
||||
}
|
||||
|
||||
|
||||
// The worker receives a message everytime the web app wants to query the index
|
||||
function handleMessage(message) {
|
||||
var id = message.data.id;
|
||||
var query = message.data.query;
|
||||
var results = queryIndex(query);
|
||||
self.postMessage({type: 'query-results', id: id, query: query, results: results});
|
||||
}
|
||||
|
||||
|
||||
// Query the index and return the processed results
|
||||
function queryIndex(query) {
|
||||
// Only return the array of paths to pages
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
Copyright 2016 Google Inc. All Rights Reserved.
|
||||
Use of this source code is governed by an MIT-style license that
|
||||
can be found in the LICENSE file at http://angular.io/license
|
||||
*/
|
||||
|
||||
import { NgZone, Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { Subject } from 'rxjs/Subject';
|
||||
import 'rxjs/add/operator/publishLast';
|
||||
import 'rxjs/add/operator/concatMap';
|
||||
import { WebWorkerClient } from 'app/shared/web-worker';
|
||||
|
||||
export interface QueryResults {
|
||||
query: string;
|
||||
results: Object[];
|
||||
}
|
||||
|
||||
const SEARCH_WORKER_URL = 'app/search/search-worker.js';
|
||||
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
private worker: WebWorkerClient;
|
||||
private ready: Observable<boolean>;
|
||||
private resultsSubject = new Subject<QueryResults>();
|
||||
get searchResults() { return this.resultsSubject.asObservable(); }
|
||||
|
||||
constructor(private zone: NgZone) {
|
||||
this.worker = new WebWorkerClient(SEARCH_WORKER_URL, zone);
|
||||
const ready = this.ready = this.worker.sendMessage<boolean>('load-index').publishLast();
|
||||
// trigger the index to be loaded immediately
|
||||
ready.connect();
|
||||
}
|
||||
|
||||
search(query: string) {
|
||||
this.ready.concatMap(ready => {
|
||||
return this.worker.sendMessage('query-index', query) as Observable<QueryResults>;
|
||||
}).subscribe(results => this.resultsSubject.next(results));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
Copyright 2016 Google Inc. All Rights Reserved.
|
||||
Use of this source code is governed by an MIT-style license that
|
||||
can be found in the LICENSE file at http://angular.io/license
|
||||
*/
|
||||
|
||||
import {NgZone, Injectable} from '@angular/core';
|
||||
import {Observable} from 'rxjs/Observable';
|
||||
|
||||
export interface WebWorkerMessage {
|
||||
type: string;
|
||||
payload: any;
|
||||
id?: number;
|
||||
}
|
||||
|
||||
export class WebWorkerClient {
|
||||
worker: Worker;
|
||||
private messageId = 0;
|
||||
|
||||
constructor(url: string, private zone: NgZone) {
|
||||
this.worker = new Worker(url);
|
||||
}
|
||||
|
||||
sendMessage<T>(type: string, payload?: any): Observable<T> {
|
||||
|
||||
return new Observable<T>(subscriber => {
|
||||
|
||||
const id = this.messageId++;
|
||||
|
||||
const handleMessage = (response: MessageEvent) => {
|
||||
const {type: responseType, id: responseId, payload: responsePayload} = response.data as WebWorkerMessage;
|
||||
if (type === responseType && id === responseId) {
|
||||
this.zone.run(() => {
|
||||
subscriber.next(responsePayload);
|
||||
subscriber.complete();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (error: ErrorEvent) => {
|
||||
// Since we do not check type and id any error from the webworker will kill all subscribers
|
||||
this.zone.run(() => subscriber.error(error));
|
||||
};
|
||||
|
||||
// Wire up the event listeners for this message
|
||||
this.worker.addEventListener('message', handleMessage);
|
||||
this.worker.addEventListener('error', handleError);
|
||||
|
||||
// Post the message to the web worker
|
||||
this.worker.postMessage({type, id, payload});
|
||||
|
||||
// At completion/error unwire the event listeners
|
||||
return () => {
|
||||
this.worker.removeEventListener('message', handleMessage);
|
||||
this.worker.removeEventListener('error', handleError);
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue