diff options
Diffstat (limited to 'packages/gitbook-plugin-search/src')
8 files changed, 394 insertions, 0 deletions
diff --git a/packages/gitbook-plugin-search/src/actions/search.js b/packages/gitbook-plugin-search/src/actions/search.js new file mode 100644 index 0000000..24151c6 --- /dev/null +++ b/packages/gitbook-plugin-search/src/actions/search.js @@ -0,0 +1,121 @@ +const { Promise, Immutable } = require('gitbook-core'); +const { List } = Immutable; + +const TYPES = require('./types'); +const Result = require('../models/Result'); + +/* + Search workflow: + + 1. Typing in the search input + 2. Trigger an update of the url + 3. An update of the url, trigger an update of search results + */ + +/** + * Start a search query + * @param {String} q + * @return {Action} + */ +function query(q) { + return (dispatch, getState, { History }) => { + const searchState = getState().search; + const currentQuery = searchState.query; + + const queryString = q ? { q } : {}; + + if (currentQuery && q) { + dispatch(History.replace({ query: queryString })); + } else { + dispatch(History.push({ query: queryString })); + } + }; +} + +/** + * Update results for a query + * @param {String} q + * @return {Action} + */ +function handleQuery(q) { + if (!q) { + return clear(); + } + + return (dispatch, getState, actions) => { + const { handlers } = getState().search; + + dispatch({ type: TYPES.START, query: q }); + + return Promise.reduce( + handlers.toArray(), + (results, handler) => { + return Promise.resolve(handler(q, dispatch, getState, actions)) + .then(handlerResults => { + return handlerResults.map(result => new Result(result)); + }) + .then(handlerResults => results.concat(handlerResults)); + }, + List() + ) + .then( + results => { + dispatch({ type: TYPES.END, query: q, results }); + } + ); + }; +} + +/** + * Refresh current search (when handlers have changed) + * @return {Action} + */ +function refresh() { + return (dispatch, getState) => { + const q = getState().search.query; + if (q) { + dispatch(handleQuery(q)); + } + }; +} + +/** + * Clear the whole search + * @return {Action} + */ +function clear() { + return { type: TYPES.CLEAR }; +} + +/** + * Register a search handler + * @param {String} name + * @param {Function} handler + * @return {Action} + */ +function registerHandler(name, handler) { + return (dispatch) => { + dispatch({ type: TYPES.REGISTER_HANDLER, name, handler }); + dispatch(refresh()); + }; +} + +/** + * Unregister a search handler + * @param {String} name + * @return {Action} + */ +function unregisterHandler(name) { + return (dispatch) => { + dispatch({ type: TYPES.UNREGISTER_HANDLER, name }); + dispatch(refresh()); + }; +} + +module.exports = { + clear, + query, + handleQuery, + registerHandler, + unregisterHandler +}; diff --git a/packages/gitbook-plugin-search/src/actions/types.js b/packages/gitbook-plugin-search/src/actions/types.js new file mode 100644 index 0000000..3cd1a89 --- /dev/null +++ b/packages/gitbook-plugin-search/src/actions/types.js @@ -0,0 +1,8 @@ + +module.exports = { + CLEAR: 'search/clear', + REGISTER_HANDLER: 'search/handlers/register', + UNREGISTER_HANDLER: 'search/handlers/unregister', + START: 'search/start', + END: 'search/end' +}; diff --git a/packages/gitbook-plugin-search/src/components/Input.js b/packages/gitbook-plugin-search/src/components/Input.js new file mode 100644 index 0000000..216a5d2 --- /dev/null +++ b/packages/gitbook-plugin-search/src/components/Input.js @@ -0,0 +1,73 @@ +const GitBook = require('gitbook-core'); +const { React } = GitBook; + +const search = require('../actions/search'); + +const ESCAPE = 27; + +const SearchInput = React.createClass({ + propTypes: { + query: React.PropTypes.string, + i18n: GitBook.PropTypes.I18n, + dispatch: GitBook.PropTypes.dispatch + }, + + onChange(event) { + const { dispatch } = this.props; + const { value } = event.currentTarget; + + dispatch(search.query(value)); + }, + + /** + * On Escape key down, clear the search field + */ + onKeyDown(e) { + const { query } = this.props; + if (e.keyCode == ESCAPE && query != '') { + e.preventDefault(); + e.stopPropagation(); + this.clearSearch(); + } + }, + + clearSearch() { + this.props.dispatch(search.query('')); + }, + + render() { + const { i18n, query } = this.props; + + let clear; + if (query != '') { + clear = ( + <span className="Search-Clear" + onClick={this.clearSearch}> + ✕ + </span> + ); + // clear = <GitBook.Icon id="x" onClick={this.clearSearch}/>; + } + + return ( + <div className="Search-Input"> + <input + type="text" + onKeyDown={this.onKeyDown} + value={query} + placeholder={i18n.t('SEARCH_PLACEHOLDER')} + onChange={this.onChange} + /> + + { clear } + </div> + ); + } +}); + +const mapStateToProps = state => { + const { query } = state.search; + return { query }; +}; + +module.exports = GitBook.connect(SearchInput, mapStateToProps); diff --git a/packages/gitbook-plugin-search/src/components/Results.js b/packages/gitbook-plugin-search/src/components/Results.js new file mode 100644 index 0000000..16a8cbd --- /dev/null +++ b/packages/gitbook-plugin-search/src/components/Results.js @@ -0,0 +1,80 @@ +const GitBook = require('gitbook-core'); +const { React } = GitBook; +const Highlight = require('react-highlighter'); + +const MAX_DESCRIPTION_SIZE = 500; + +const Result = React.createClass({ + propTypes: { + result: React.PropTypes.object, + query: React.PropTypes.string + }, + + render() { + const { result, query } = this.props; + + let summary = result.body.trim(); + if (summary.length > MAX_DESCRIPTION_SIZE) { + summary = summary.slice(0, MAX_DESCRIPTION_SIZE).trim() + '...'; + } + + return ( + <div className="Search-ResultContainer"> + <GitBook.InjectedComponent matching={{ role: 'search:result' }} props={{ result, query }}> + <div className="Search-Result"> + <h3> + <GitBook.Link to={result.url}>{result.title}</GitBook.Link> + </h3> + <p> + <Highlight + matchElement="span" + matchClass="Search-MatchSpan" + search={query}> + {summary} + </Highlight> + </p> + </div> + </GitBook.InjectedComponent> + </div> + ); + } +}); + +const SearchResults = React.createClass({ + propTypes: { + i18n: GitBook.PropTypes.I18n, + results: GitBook.PropTypes.list, + query: React.PropTypes.string, + children: React.PropTypes.node + }, + + render() { + const { i18n, query, results, children } = this.props; + + if (!query) { + return React.Children.only(children); + } + + return ( + <div className="Search-ResultsContainer"> + <GitBook.InjectedComponent matching={{ role: 'search:results' }} props={{ results, query }}> + <div className="Search-Results"> + <h1>{i18n.t('SEARCH_RESULTS_TITLE', { query, count: results.size })}</h1> + <div className="Search-Results"> + {results.map((result, i) => { + return <Result key={i} result={result} query={query} />; + })} + </div> + </div> + </GitBook.InjectedComponent> + </div> + ); + } +}); + +const mapStateToProps = (state) => { + const { results, query } = state.search; + return { results, query }; +}; + +module.exports = GitBook.connect(SearchResults, mapStateToProps); diff --git a/packages/gitbook-plugin-search/src/index.js b/packages/gitbook-plugin-search/src/index.js new file mode 100644 index 0000000..f8c59aa --- /dev/null +++ b/packages/gitbook-plugin-search/src/index.js @@ -0,0 +1,33 @@ +const GitBook = require('gitbook-core'); + +const SearchInput = require('./components/Input'); +const SearchResults = require('./components/Results'); +const reducers = require('./reducers'); +const Search = require('./actions/search'); + +/** + * Url of the page changed, we update the search according to this. + * @param {GitBook.Location} location + * @param {Function} dispatch + */ +const onLocationChange = (location, dispatch) => { + const { query } = location; + const q = query.get('q'); + + dispatch(Search.handleQuery(q)); +}; + +module.exports = GitBook.createPlugin({ + activate: (dispatch, getState, { History, Components }) => { + // Register the navigation handler + dispatch(History.listen(onLocationChange)); + + // Register components + dispatch(Components.registerComponent(SearchInput, { role: 'search:container:input' })); + dispatch(Components.registerComponent(SearchResults, { role: 'search:container:results' })); + }, + reduce: reducers, + actions: { + Search + } +}); diff --git a/packages/gitbook-plugin-search/src/models/Result.js b/packages/gitbook-plugin-search/src/models/Result.js new file mode 100644 index 0000000..0012b2b --- /dev/null +++ b/packages/gitbook-plugin-search/src/models/Result.js @@ -0,0 +1,20 @@ +const GitBook = require('gitbook-core'); +const { Record } = GitBook.Immutable; + +const DEFAULTS = { + url: String(''), + title: String(''), + body: String('') +}; + +class Result extends Record(DEFAULTS) { + constructor(spec) { + if (!spec.url || !spec.title) { + throw new Error('"url" and "title" are required to create a search result'); + } + + super(spec); + } +} + +module.exports = Result; diff --git a/packages/gitbook-plugin-search/src/reducers/index.js b/packages/gitbook-plugin-search/src/reducers/index.js new file mode 100644 index 0000000..bfce2bd --- /dev/null +++ b/packages/gitbook-plugin-search/src/reducers/index.js @@ -0,0 +1,3 @@ +const GitBook = require('gitbook-core'); + +module.exports = GitBook.createReducer('search', require('./search')); diff --git a/packages/gitbook-plugin-search/src/reducers/search.js b/packages/gitbook-plugin-search/src/reducers/search.js new file mode 100644 index 0000000..b960a77 --- /dev/null +++ b/packages/gitbook-plugin-search/src/reducers/search.js @@ -0,0 +1,56 @@ +const GitBook = require('gitbook-core'); +const { Record, List, OrderedMap } = GitBook.Immutable; + +const TYPES = require('../actions/types'); + +const SearchState = Record({ + // Is the search being processed + loading: Boolean(false), + // Current query + query: String(''), + // Current list of results + results: List(), + // Search handlers + handlers: OrderedMap() +}); + +module.exports = (state = SearchState(), action) => { + switch (action.type) { + + case TYPES.CLEAR: + return state.merge({ + loading: false, + query: '', + results: List() + }); + + case TYPES.START: + return state.merge({ + loading: true, + query: action.query + }); + + case TYPES.END: + if (action.query !== state.query) { + return state; + } + + return state.merge({ + loading: false, + results: action.results + }); + + case TYPES.REGISTER_HANDLER: + return state.merge({ + handlers: state.handlers.set(action.name, action.handler) + }); + + case TYPES.UNREGISTER_HANDLER: + return state.merge({ + handlers: state.handlers.remove(action.name) + }); + + default: + return state; + } +}; |