diff --git a/aio/.angular-cli.json b/aio/.angular-cli.json index d08d31e3d5..57a6fab8d2 100644 --- a/aio/.angular-cli.json +++ b/aio/.angular-cli.json @@ -10,6 +10,7 @@ "assets": [ "assets", "content", + "app/search/search-worker.js", "favicon.ico" ], "index": "index.html", diff --git a/aio/src/app/app.component.html b/aio/src/app/app.component.html index d64217206c..416a22b935 100644 --- a/aio/src/app/app.component.html +++ b/aio/src/app/app.component.html @@ -2,7 +2,7 @@ - + @@ -10,11 +10,15 @@ -
+
diff --git a/aio/src/app/app.component.ts b/aio/src/app/app.component.ts index 1668793ecd..0e33a3ea75 100644 --- a/aio/src/app/app.component.ts +++ b/aio/src/app/app.component.ts @@ -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; navigationViews: Observable; + searchResults: Observable; - 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); + } } diff --git a/aio/src/app/app.module.ts b/aio/src/app/app.module.ts index 875a8f3708..d536f5a17b 100644 --- a/aio/src/app/app.module.ts +++ b/aio/src/app/app.module.ts @@ -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 ], diff --git a/aio/src/app/search/search-worker-client.ts b/aio/src/app/search/search-worker-client.ts deleted file mode 100644 index b4115371ea..0000000000 --- a/aio/src/app/search/search-worker-client.ts +++ /dev/null @@ -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; - 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((subscriber: Subscriber) => { - - // 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); - }; - }); - } -} diff --git a/aio/src/app/search/search-worker.js b/aio/src/app/search/search-worker.js index 8528060e18..1e5c5d3b04 100644 --- a/aio/src/app/search/search-worker.js +++ b/aio/src/app/search/search-worker.js @@ -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 diff --git a/aio/src/app/search/search.service.ts b/aio/src/app/search/search.service.ts new file mode 100644 index 0000000000..d84e7af67e --- /dev/null +++ b/aio/src/app/search/search.service.ts @@ -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; + private resultsSubject = new Subject(); + 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('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; + }).subscribe(results => this.resultsSubject.next(results)); + } +} diff --git a/aio/src/app/shared/web-worker.ts b/aio/src/app/shared/web-worker.ts new file mode 100644 index 0000000000..c5fe6b27f0 --- /dev/null +++ b/aio/src/app/shared/web-worker.ts @@ -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(type: string, payload?: any): Observable { + + return new Observable(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); + }; + }); + } +}