diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..339cbf23 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v5.10.0 diff --git a/lib/Registry.js b/lib/Registry.js index 43241647..5055c9df 100644 --- a/lib/Registry.js +++ b/lib/Registry.js @@ -5,7 +5,8 @@ * is populated during compilation in the "index" phase. */ function Registry() { - this.entries = {}; + this.indices = {}; + this.tokens = []; } /** @@ -30,11 +31,11 @@ function Registry() { * index. Things like doc ID or title or such. */ Registry.prototype.add = function(id, entry) { - this.entries[id] = entry; + this.indices[id] = entry; }; Registry.prototype.get = function(id) { - return this.entries[id]; + return this.indices[id]; }; /** @@ -43,12 +44,19 @@ Registry.prototype.get = function(id) { * is the index data. */ Registry.prototype.toJSON = function() { - return this.entries; + return { + indices: this.indices, + tokens: this.tokens + }; +}; + +Registry.prototype.addSearchToken = function(token) { + this.tokens.push(token); }; Object.defineProperty(Registry.prototype, 'size', { get: function() { - return Object.keys(this.entries).length; + return Object.keys(this.indices).length; } }); diff --git a/lib/plugins/core/config.js b/lib/plugins/core/config.js index 012168b8..4e142107 100644 --- a/lib/plugins/core/config.js +++ b/lib/plugins/core/config.js @@ -225,5 +225,7 @@ module.exports = { * * Only works when using the single page layout. */ - scrollSpying: false + scrollSpying: false, + + spotlight: true, }; diff --git a/lib/plugins/core/write.js b/lib/plugins/core/write.js index def2828a..10b6f140 100644 --- a/lib/plugins/core/write.js +++ b/lib/plugins/core/write.js @@ -114,6 +114,8 @@ function generateRuntimeConfigScript(compiler, config, database) { runtimeConfig.pluginCount += 1; } + runtimeConfig.registry = compiler.registry.toJSON(); + console.log('Registered UI plugins:', compiler.assets.pluginScripts.map(function(filePath) { return path.basename(filePath); diff --git a/package.json b/package.json index f464cf28..ae561b64 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tinydoc", - "version": "4.0.1", + "version": "4.0.2", "description": "Fast and sleek and modular documentation generator.", "main": "lib/index.js", "bin": { @@ -37,8 +37,10 @@ "commander": "^2.8.1", "css-loader": "0.9.1", "deep-get-set": "^0.1.1", + "dom-contains": "^0.2.0", "file-loader": "^0.8.4", "fs-extra": "0.18.0", + "fuse.js": "2.2.0", "git-log-parser": "^1.1.0", "glob": "5.0.3", "htmlparser2": "^3.8.3", diff --git a/ui/css/components/banner.less b/ui/css/components/banner.less index 9b523eca..02bcbef3 100644 --- a/ui/css/components/banner.less +++ b/ui/css/components/banner.less @@ -47,6 +47,7 @@ text-align: center; min-width: 100px; margin-left: -@banner-border-dim; + cursor: pointer; a { display: block; diff --git a/ui/css/components/spotlight.less b/ui/css/components/spotlight.less new file mode 100644 index 00000000..a4369295 --- /dev/null +++ b/ui/css/components/spotlight.less @@ -0,0 +1,85 @@ +.spotlight { + &__wrapper { + background: rgba(0,0,0,.4); + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 2000; + padding-top: 30vh; + font-family: Slack-Lato,appleLogo,sans-serif; + } + + width: 460px; + display: block; + box-shadow: 0 1px 10px rgba(0,0,0,.5); + background: #fff; + border-radius: 8px; + padding: .75rem 1rem; + margin: 0 auto; + + &__input { + width: 100%; + max-width: 100%; + outline: 0; + line-height: normal; + + font-size: 2rem; + font-family: appleLogo,sans-serif; + font-weight: 700; + padding: 1rem; + border: 1px solid #a0a0a2!important; + border-radius: 6px; + box-shadow: none!important; + color: #2c2d30; + } + + &__results { + padding: 0; + margin: 0; + max-height: 30vh; + overflow: auto; + + &:not(:empty) { + margin-top: 0.5rem; + } + } + + &__result a { + display: block; + text-decoration: none; + border-radius: 6px; + padding: 0.1rem 0.5rem; + } + + &__help { + margin: 0 0 .5rem; + color: #8b898f; + font-size: .7rem; + display: block; + + &-entry { + display: inline-block; + margin-left: 0.25rem; + } + } +} + +.schemable({ + .spotlight { + &__highlighted-term { + background-color: @code; + } + + &__result { + list-style: none; + color: @base0; + + &--active a, a:hover, a:focus { + background: @link; + color: @base3; + } + } + } +}); \ No newline at end of file diff --git a/ui/css/index.less b/ui/css/index.less index f9f7357e..1c174ce4 100644 --- a/ui/css/index.less +++ b/ui/css/index.less @@ -73,6 +73,13 @@ ul, ol { font-family: inherit; } +.float--right { + float: right; +} +.float--left { + float: left; +} + .schemable({ background-color: @base3; color: @base0; @@ -152,3 +159,4 @@ ul, ol { @import 'components/hot-item-indicator'; @import 'components/anchorable-heading'; @import 'components/resizable'; +@import 'components/spotlight'; diff --git a/ui/index.js b/ui/index.js index 79488cf0..2493b1c6 100644 --- a/ui/index.js +++ b/ui/index.js @@ -51,6 +51,12 @@ tinydoc.onReady(function(registrar) { {registrar.getRouteMap()} + + + {config.spotlight && ( + + )} + @@ -93,7 +102,7 @@ const Root = React.createClass({ trackLayoutChange() { this.setState({ layoutChanged: true }); - } + }, }); module.exports = Root; \ No newline at end of file diff --git a/ui/screens/Search.js b/ui/screens/Search.js new file mode 100644 index 00000000..bc773862 --- /dev/null +++ b/ui/screens/Search.js @@ -0,0 +1,12 @@ +const React = require('react'); +const Spotlight = require('components/Spotlight'); + +const Search = React.createClass({ + render() { + return ( + + ); + } +}); + +module.exports = Search; diff --git a/ui/shared/components/Banner.js b/ui/shared/components/Banner.js index 42ce8d9d..b64955e1 100644 --- a/ui/shared/components/Banner.js +++ b/ui/shared/components/Banner.js @@ -3,6 +3,7 @@ var Link = require("components/Link"); var Outlet = require("components/Outlet"); var config = require('config'); var Icon = require('components/Icon'); +const AppState = require('core/AppState'); var BannerItem = React.createClass({ propTypes: { @@ -49,7 +50,13 @@ var Banner = React.createClass({ - + {config.spotlight && ( + + + + )} + + {config.showSettingsLinkInBanner && ( @@ -62,6 +69,15 @@ var Banner = React.createClass({ ); + }, + + toggleSpotlight() { + if (AppState.isSpotlightOpen()) { + AppState.closeSpotlight(); + } + else { + AppState.openSpotlight(); + } } }); diff --git a/ui/shared/components/Spotlight.js b/ui/shared/components/Spotlight.js new file mode 100644 index 00000000..17fa2a55 --- /dev/null +++ b/ui/shared/components/Spotlight.js @@ -0,0 +1,186 @@ +const React = require('react'); +const config = require('config'); +const { debounce } = require('lodash'); +const TokenSearcher = require('core/TokenSearcher'); +const Link = require('components/Link'); +const classSet = require('classnames'); +const { func, } = React.PropTypes; + +const Spotlight = React.createClass({ + propTypes: { + onChange: func, // emitted when a document has been selected and jumped to + }, + + getInitialState() { + return { + results: [], + lastSearchTerm: '', + cursor: 0 + }; + }, + + componentWillMount() { + this.searcher = TokenSearcher(config.registry.tokens); + this.debouncedSearch = debounce(this.searcher.search, 100); + }, + + render() { + const { results } = this.state; + + return ( + + + + + Jump to a document + + + + + tab or ↑↓ to navigate + + + {' '} + + + ↵ to select + + + {' '} + + + esc to dismiss + + + + + + + + {results.length === 0 && this.state.lastSearchTerm.length > 0 && ( + Nothing matched your query. 😞 + )} + + {results.map(this.renderResult)} + + + + ); + }, + + renderResult(result, index) { + const token = result.item; + const url = token.link.url; + + return ( + + + {token[result.matches[0].key]} + + + ); + + // {highlight(token[result.matches[0].key], result.matches[0].indices)} + }, + + search(e) { + this.setState({ + cursor: 0, + lastSearchTerm: e.target.value, + results: this.searcher.search(e.target.value) + }); + }, + + navigate(e) { + if (e.keyCode === 40) { + e.preventDefault(); + this.selectNext(); + } + else if (e.keyCode === 38) { + e.preventDefault(); + this.selectPrev(); + } + else if (e.keyCode === 9) { + e.preventDefault(); + + if (e.shiftKey) { + this.selectPrev(); + } + else { + this.selectNext(); + } + } + else if (e.keyCode === 13) { + e.preventDefault(); + this.activateSelected(); + + if (this.props.onChange) { + this.props.onChange(); + } + } + }, + + selectNext() { + this.setState({ + cursor: this.state.cursor === this.state.results.length - 1 ? 0 : this.state.cursor + 1 + }); + + }, + + selectPrev() { + this.setState({ + cursor: this.state.cursor === 0 ? this.state.results.length - 1 : this.state.cursor - 1 + }); + }, + + activateSelected() { + React.findDOMNode(this.refs[`link__${this.state.cursor}`]).click(); + } +}); + +// function highlight(term, matches) { +// if (matches.length === 0) { +// return term; +// } + +// return matches.reduce(function(buffer, match, index) { +// return buffer.concat([ +// // any leading characters that were not matched: +// index === 0 && match[0] > 0 && { text: term.slice(0, match[0]) }, + +// // the substring between the last match and this one: +// index > 0 && matches[index-1][1] < match[0] && +// { text: term.slice(matches[index-1][1]+1, match[0]) }, + +// // the match body +// { text: term.slice(match[0], match[1] + 1), highlighted: true }, + +// // add any trailing, non-matched characters +// index === matches.length - 1 && match[1] < term.length && +// { text: term.slice(match[1]+1) }, +// ]); +// }, []).filter(x => !!x).map(function(entry, index) { +// return ( +// +// {entry.text} +// +// ); +// }); +// } + +module.exports = Spotlight; diff --git a/ui/shared/components/SpotlightManager.js b/ui/shared/components/SpotlightManager.js new file mode 100644 index 00000000..fa9d0d0b --- /dev/null +++ b/ui/shared/components/SpotlightManager.js @@ -0,0 +1,89 @@ +const React = require('react'); +const Spotlight = require('components/Spotlight'); +const contains = require('dom-contains'); +const { KC_ESCAPE } = require('constants'); +const { bool, func, } = React.PropTypes; + +const SpotlightManager = React.createClass({ + propTypes: { + active: bool.isRequired, + onOpen: func.isRequired, + onClose: func.isRequired, + }, + + componentDidMount() { + window.addEventListener('keydown', this.handleGlobalKeybindings, true); + + if (this.props.active) { + this.closeSpotlightOnExternalClicks(); + } + }, + + componentDidUpdate(prevProps) { + if (!prevProps.active && this.props.active) { + this.closeSpotlightOnExternalClicks(); + } + else if (prevProps.active && !this.props.active) { + this.stopClosingSpotlightOnExternalClicks(); + } + }, + + componentWillUnmount() { + window.removeEventListener('keydown', this.handleGlobalKeybindings, true); + }, + + render() { + if (this.props.active) { + return ( + + ); + } + else { + return null; + } + }, + + openSpotlight() { + this.props.onOpen(); + }, + + closeSpotlight() { + this.props.onClose(); + }, + + handleGlobalKeybindings(e) { + const keyName = String.fromCharCode(e.which).toLowerCase(); + + if (keyName === 'k' && e.ctrlKey) { + e.preventDefault(); + + if (this.props.active) { + this.closeSpotlight(); + } + else { + this.openSpotlight(); + } + } + else if (e.which === KC_ESCAPE) { + e.preventDefault(); + + this.closeSpotlight(); + } + }, + + closeSpotlightOnExternalClicks() { + window.addEventListener('click', this.doCloseSpotlightOnExternalClicks, false); + }, + + stopClosingSpotlightOnExternalClicks() { + window.removeEventListener('click', this.doCloseSpotlightOnExternalClicks, false); + }, + + doCloseSpotlightOnExternalClicks(e) { + if (contains(e.target, React.findDOMNode(this))) { + this.closeSpotlight(); + } + } +}); + +module.exports = SpotlightManager; diff --git a/ui/shared/constants.js b/ui/shared/constants.js index 86d97cd9..1d5981b2 100644 --- a/ui/shared/constants.js +++ b/ui/shared/constants.js @@ -5,6 +5,8 @@ exports.QUERY_ON = "1"; exports.QUERY_OFF = undefined; exports.KC_RETURN = 13; +exports.KC_K = 75; +exports.KC_ESCAPE = 27; exports.AVAILABLE_SCHEMES = [ 'plain', diff --git a/ui/shared/core/AppState.js b/ui/shared/core/AppState.js index 4bcb689f..a88410c3 100644 --- a/ui/shared/core/AppState.js +++ b/ui/shared/core/AppState.js @@ -4,7 +4,8 @@ const EventEmitter = require('core/EventEmitter'); const invariant = require('utils/invariant'); let state = { - layout: config.layout + layout: config.layout, + spotlightOpen: false, }; let AppState = EventEmitter([ 'change', 'layoutChange' ]); @@ -24,4 +25,22 @@ AppState.getLayout = function() { return state.layout; }; +AppState.openSpotlight = function() { + if (!AppState.isSpotlightOpen()) { + state.spotlightOpen = true; + AppState.emit('change'); + } +}; + +AppState.closeSpotlight = function() { + if (AppState.isSpotlightOpen()) { + state.spotlightOpen = false; + AppState.emit('change'); + } +}; + +AppState.isSpotlightOpen = function() { + return state.spotlightOpen; +}; + module.exports = AppState; diff --git a/ui/shared/core/TokenSearcher.js b/ui/shared/core/TokenSearcher.js new file mode 100644 index 00000000..4d83e377 --- /dev/null +++ b/ui/shared/core/TokenSearcher.js @@ -0,0 +1,47 @@ +const Fuse = require('fuse.js'); + +function TokenSearcher(tokens) { + const fuse = new Fuse(tokens, { + threshold: 0.4, + distance: 100, + include: [ 'score', 'matches' ], + keys: [ + { name: '$1', weight: 1, }, + { name: '$2', weight: 1 / 2, }, + { name: '$3', weight: 1 / 4, }, + ], + + getFn(obj, path) { + return obj[path]; + }, + + sortFn(a, b) { + const scoreA = computeMatchRangeScore(a); + const scoreB = computeMatchRangeScore(b); + + if (scoreA === scoreB) { + return 0; + } + else if (scoreA > scoreB) { + return 1; + } + else { + return -1; + } + } + }); + + return { + search(term) { + return fuse.search(term.trim()); + } + }; +} + +function computeMatchRangeScore(item) { + return item.output[0].matchedIndices.reduce((acc, x) => { + return acc + (x[1] - x[0] + 1); + }, 0) + item.output[0].matchedIndices.length; +} + +module.exports = TokenSearcher; diff --git a/ui/shared/core/__tests__/TokenSearcher.test.js b/ui/shared/core/__tests__/TokenSearcher.test.js new file mode 100644 index 00000000..3872b973 --- /dev/null +++ b/ui/shared/core/__tests__/TokenSearcher.test.js @@ -0,0 +1,67 @@ +const Subject = require("../TokenSearcher"); +const { assert } = require('chai'); + +describe("Core::TokenSearcher", function() { + const samples = [ + { + term: 'foo b', + tokens: [ + { $1: 'Foo - LOL' }, + { $1: 'Zoo' }, + { $1: 'Foo - Bar' }, + ], + expected: [ 2, 0 ], + }, + + { + term: 'java testing', + tokens: [ + { $1: 'JavaScript - Getting Started' }, + { $1: 'JavaScript Testing - Mojo' }, + { $1: 'JavaScript Testing - Testing Promise-basEd Asynchronous Code' }, + { $1: 'Zoo' }, + ], + + expected: [ 1, 2 ], + }, + { + term: 'async', + tokens: [ + { $1: 'JavaScript - Getting Started' }, + { $1: 'JavaScript Testing - Mojo' }, + { $1: 'JavaScript Testing - Testing Promise Asynchronous Code' }, + { $1: 'Zoo' }, + ], + + expected: [ 2 ], + }, + ]; + + samples.forEach(function(sample, index) { + it(sample.message || `searching for "${sample.term}" (sample #${index})`, function() { + assertRanked( + Subject(sample.tokens).search(sample.term), + sample.expected.map(x => sample.tokens[x]['$1']) + ); + }); + }); + + function assertRanked(results, expectedTokens) { + const actualTokens = results.map(x => x.item['$1']); + + assert(results.length === expectedTokens.length, + ` + Expected ${expectedTokens.length} tokens to be matched, not ${results.length}. + Expected: + ${expectedTokens.map(x => `\n - ${x}`).join('')} + + Actual: + ${actualTokens.map(x => `\n - ${x}`).join('')} + `); + + expectedTokens.forEach(function(expected, index) { + const actual = actualTokens[index]; + assert(actual === expected, `Expected token at ${index} to be "${expected}" but got "${actual}"`); + }); + } +}); \ No newline at end of file