feat(aio): add search service and search UI
This commit is contained in:
parent
71e22b8d11
commit
dca83ec738
@ -10,6 +10,7 @@
|
|||||||
"assets": [
|
"assets": [
|
||||||
"assets",
|
"assets",
|
||||||
"content",
|
"content",
|
||||||
|
"app/search/search-worker.js",
|
||||||
"favicon.ico"
|
"favicon.ico"
|
||||||
],
|
],
|
||||||
"index": "index.html",
|
"index": "index.html",
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<button *ngIf="isHamburgerVisible" class="hamburger" md-button (click)="sidenav.toggle()"><md-icon>menu</md-icon></button>
|
<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>
|
<aio-top-menu [nodes]="(navigationViews | async)?.TopBar"></aio-top-menu>
|
||||||
<md-input-container >
|
<md-input-container >
|
||||||
<input #search mdInput placeholder="Search">
|
<input mdInput placeholder="Search" (keyup)="onSearch($event)">
|
||||||
</md-input-container>
|
</md-input-container>
|
||||||
<span class="fill-remaining-space"></span>
|
<span class="fill-remaining-space"></span>
|
||||||
</md-toolbar>
|
</md-toolbar>
|
||||||
@ -10,11 +10,15 @@
|
|||||||
<md-sidenav-container class="sidenav-container" (window:resize)="onResize($event.target.innerWidth)">
|
<md-sidenav-container class="sidenav-container" (window:resize)="onResize($event.target.innerWidth)">
|
||||||
|
|
||||||
<md-sidenav #sidenav class="sidenav" [opened]="isSideBySide" [mode] = "this.isSideBySide ? 'side' : 'over'">
|
<md-sidenav #sidenav class="sidenav" [opened]="isSideBySide" [mode] = "this.isSideBySide ? 'side' : 'over'">
|
||||||
|
|
||||||
<aio-nav-menu [nodes]="(navigationViews | async)?.SideNav"></aio-nav-menu>
|
<aio-nav-menu [nodes]="(navigationViews | async)?.SideNav"></aio-nav-menu>
|
||||||
</md-sidenav>
|
</md-sidenav>
|
||||||
|
|
||||||
<section class="sidenav-content">
|
<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>
|
<aio-doc-viewer [doc]="currentDocument | async"></aio-doc-viewer>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@ import { Component, ViewChild, OnInit } from '@angular/core';
|
|||||||
import { Observable } from 'rxjs/Observable';
|
import { Observable } from 'rxjs/Observable';
|
||||||
import { DocumentService, DocumentContents } from 'app/documents/document.service';
|
import { DocumentService, DocumentContents } from 'app/documents/document.service';
|
||||||
import { NavigationService, NavigationViews } from 'app/navigation/navigation.service';
|
import { NavigationService, NavigationViews } from 'app/navigation/navigation.service';
|
||||||
|
import { SearchService, QueryResults } from 'app/search/search.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'aio-shell',
|
selector: 'aio-shell',
|
||||||
@ -91,10 +92,12 @@ export class AppComponent implements OnInit {
|
|||||||
|
|
||||||
currentDocument: Observable<DocumentContents>;
|
currentDocument: Observable<DocumentContents>;
|
||||||
navigationViews: Observable<NavigationViews>;
|
navigationViews: Observable<NavigationViews>;
|
||||||
|
searchResults: Observable<QueryResults>;
|
||||||
|
|
||||||
constructor(documentService: DocumentService, navigationService: NavigationService) {
|
constructor(documentService: DocumentService, navigationService: NavigationService, private searchService: SearchService) {
|
||||||
this.currentDocument = documentService.currentDocument;
|
this.currentDocument = documentService.currentDocument;
|
||||||
this.navigationViews = navigationService.navigationViews;
|
this.navigationViews = navigationService.navigationViews;
|
||||||
|
this.searchResults = searchService.searchResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
@ -104,4 +107,10 @@ export class AppComponent implements OnInit {
|
|||||||
onResize(width) {
|
onResize(width) {
|
||||||
this.isSideBySide = width > this.sideBySideWidth;
|
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 { LocationService } from 'app/shared/location.service';
|
||||||
import { NavigationService } from 'app/navigation/navigation.service';
|
import { NavigationService } from 'app/navigation/navigation.service';
|
||||||
import { DocumentService } from 'app/documents/document.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 { TopMenuComponent } from 'app/layout/top-menu/top-menu.component';
|
||||||
import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
|
import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
|
||||||
import { NavItemComponent } from 'app/layout/nav-item/nav-item.component';
|
import { NavItemComponent } from 'app/layout/nav-item/nav-item.component';
|
||||||
@ -54,6 +55,7 @@ import { LinkDirective } from 'app/shared/link.directive';
|
|||||||
LocationService,
|
LocationService,
|
||||||
NavigationService,
|
NavigationService,
|
||||||
DocumentService,
|
DocumentService,
|
||||||
|
SearchService,
|
||||||
Platform
|
Platform
|
||||||
],
|
],
|
||||||
entryComponents: [ embeddedComponents ],
|
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 */
|
/* eslint-env worker */
|
||||||
/* global importScripts, lunr */
|
/* global importScripts, lunr */
|
||||||
|
|
||||||
|
var SEARCH_TERMS_URL = '/content/docs/app/search-data.json';
|
||||||
|
|
||||||
importScripts('https://unpkg.com/lunr@0.7.2');
|
importScripts('https://unpkg.com/lunr@0.7.2');
|
||||||
|
|
||||||
var index = createIndex();
|
var index = createIndex();
|
||||||
var pages = {};
|
var pages = {};
|
||||||
|
|
||||||
makeRequest('search-data.json', loadIndex);
|
|
||||||
|
|
||||||
self.onmessage = handleMessage;
|
self.onmessage = handleMessage;
|
||||||
|
|
||||||
// Create the lunr index - the docs should be an array of objects, each object containing
|
// 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
|
// Use XHR to make a request to the server
|
||||||
function makeRequest(url, callback) {
|
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();
|
var searchDataRequest = new XMLHttpRequest();
|
||||||
searchDataRequest.onload = function() {
|
searchDataRequest.onload = function() {
|
||||||
callback(JSON.parse(this.responseText));
|
callback(JSON.parse(this.responseText));
|
||||||
@ -43,19 +72,8 @@ function loadIndex(searchInfo) {
|
|||||||
index.add(page);
|
index.add(page);
|
||||||
pages[page.path] = 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
|
// Query the index and return the processed results
|
||||||
function queryIndex(query) {
|
function queryIndex(query) {
|
||||||
// Only return the array of paths to pages
|
// Only return the array of paths to pages
|
||||||
|
40
aio/src/app/search/search.service.ts
Normal file
40
aio/src/app/search/search.service.ts
Normal file
@ -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));
|
||||||
|
}
|
||||||
|
}
|
59
aio/src/app/shared/web-worker.ts
Normal file
59
aio/src/app/shared/web-worker.ts
Normal file
@ -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…
x
Reference in New Issue
Block a user