Просмотр исходного кода

Merge pull request #254 from weseek/master

release v2.3.8
Yuki Takei 8 лет назад
Родитель
Сommit
de2ce2ef49

+ 11 - 0
CHANGES.md

@@ -1,6 +1,17 @@
 CHANGES
 ========
 
+## 2.3.8
+
+* Feature: Suggest page path when creating pages
+* Improvement: Prevent keyboard shortcuts when modal is opened
+* Improvement: PageHistory UI
+* Improvement: Ensure to scroll when edit button of section clicked
+* Improvement: Enabled to toggle the style for active line
+* Support: Upgrade libs
+    * style-loader
+    * react-codemirror2
+
 ## 2.3.7
 
 * Fix: Open popups when `Ctrl+C` pressed

+ 3 - 3
README.md

@@ -26,15 +26,15 @@ Why crowi-plus?
   * Find plugins from [npm](https://www.npmjs.com/browse/keyword/crowi-plugin) or [github](https://github.com/search?q=topic%3Acrowi-plugin)!
 * **Faster**
   * Optimize client-side code chunks by Webpack
-  * Adopt [date-fns](https://github.com/date-fns/date-fns) and omit Moment.js
-  * Adopt the fastest logger [pino](https://github.com/pinojs/pino)
+  * Optimize the performance when live preview
+  * Adopt faster libs([date-fns](https://github.com/date-fns/date-fns), [pino](https://github.com/pinojs/pino))
   * Using CDN
 * **Secure**
   * Prevent XSS (Cross Site Scripting)
   * Upgrade jQuery to 3.x and other insecure libs
   * The official Crowi status is [![dependencies Status](https://david-dm.org/crowi/crowi/status.svg)](https://david-dm.org/crowi/crowi) [![devDependencies Status](https://david-dm.org/crowi/crowi/dev-status.svg)](https://david-dm.org/crowi/crowi?type=dev)
 * **Convenient**
-  * Support LDAP Authentication
+  * Support Authentication with LDAP / Active Directory 
   * Slack Incoming Webhooks Integration
   * [Miscellaneous features](https://github.com/weseek/crowi-plus/wiki/Additional-Features)
 * **[Docker Ready][dockerhub]**

+ 1 - 1
lib/views/_form.html

@@ -28,7 +28,7 @@
     </button>#}
 
     <div class="pull-left">
-      <div id="page-editor-theme-selector"></div>
+      <div id="page-editor-options-selector"></div>
     </div>
 
     <div class="pull-right form-inline page-form-setting" id="page-form-setting" data-slack-configured="{{ slackConfigured() }}">

+ 7 - 3
lib/views/modal/create_page.html

@@ -16,7 +16,7 @@
             </div>
             <div class="col-xs-10">
               <span class="page-today-prefix">{{ userPageRoot(user) }}/</span>
-              <input type="text" data-prefix="{{ userPageRoot(user) }}/" class="page-today-input1 form-control" value="{{ t('Memo') }}" id="" name="">
+              <input type="text" data-prefix="{{ userPageRoot(user) }}/" class="page-today-input1 form-control text-center" value="{{ t('Memo') }}" id="" name="">
               <span class="page-today-suffix">/{{ now|datetz('Y/m/d') }}/</span>
               <input type="text" data-prefix="/{{ now|datetz('Y/m/d') }}/" class="page-today-input2 form-control" id="page-today-input2" name="" placeholder="{{ t('Input page name (optional)') }}">
             </div>
@@ -29,11 +29,15 @@
 
         <form class="form-horizontal" id="create-page-under-tree" role="form">
           <fieldset>
-            <div class="col-xs-12">
+            <div class="col-xs-12 create-page-under-tree-label">
               <h4>{{ t('Create under', parentPath(path)) }}</h4>
             </div>
             <div class="col-xs-10">
-              <input type="text" value="{{ parentPath(path) }}" class="page-name-input form-control " placeholder="{{ t('Input page name') }}" required>
+              {% if searchConfigured() %}
+              <div class="clearfix" id="page-name-inputter"></div>
+              {% else %}
+              <input type="text" value="{{ parentPath(path) }}" class="page-name-input form-control " placeholder="{{ t('Input page name') }}" required />
+              {% endif %}
             </div>
             <div class="col-xs-2">
               <button type="submit" class="btn btn-primary">{{ t('Create') }}</button>

+ 3 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "crowi-plus",
-  "version": "2.3.7-RC",
+  "version": "2.3.8-RC",
   "description": "Enhanced Crowi",
   "tags": [
     "wiki",
@@ -114,7 +114,7 @@
     "react-bootstrap": "^0.32.0",
     "react-bootstrap-typeahead": "^2.0.2",
     "react-clipboard.js": "^1.1.2",
-    "react-codemirror2": "^3.0.7",
+    "react-codemirror2": "^4.0.0",
     "react-dom": "^16.0.0",
     "react-dropzone": "^4.2.7",
     "redis": "^2.7.1",
@@ -123,7 +123,7 @@
     "sass-loader": "^6.0.3",
     "socket.io": "^2.0.3",
     "socket.io-client": "^2.0.3",
-    "style-loader": "^0.19.0",
+    "style-loader": "^0.20.1",
     "swig-templates": "^2.0.2",
     "throttle-debounce": "^1.0.1",
     "toastr": "^2.1.2",

+ 13 - 1
resource/css/_form.scss

@@ -209,10 +209,22 @@
     margin-bottom: 0;
   }
 
-  #page-editor-theme-selector .theme-selector {
+  #page-editor-options-selector {
     label {
       margin-right: 0.5em;
     }
+
+    .btn.btn-style-active-line {
+      &:hover:not(.active), &:focus:not(.active) {
+        background-color: inherit;
+      }
+    }
+  }
+
+  @media (max-width: $screen-xs-max) { // {{{ less than smartphone size
+    #page-editor-options-selector {
+      display: none;
+    }
   }
 } // }}}
 

+ 7 - 15
resource/css/crowi.scss

@@ -130,21 +130,6 @@ footer {
 
     form {
 
-      input.form-control {
-        border: none;
-        box-shadow: none;
-        border-bottom: dotted 1px #444;
-        border-radius: 0;
-        padding: 6px;
-        height: 34px;
-        font-weight: bold;
-        background: #f0f0f0;
-
-        &:focus {
-          background: #ddd;
-        }
-      }
-
       .page-name-addons {
         position: absolute;
         top: 7px;
@@ -166,6 +151,13 @@ footer {
         // width: 100%;
         display: inline-block;
       }
+      #page-name-inputter input {
+        min-width: 300px; // Workaround to display placeholder.
+                          //   cf https://github.com/ericgio/react-bootstrap-typeahead/issues/256
+      }
+      .create-page-under-tree-label code {
+        font-family: $font-family-monospace-not-strictly;
+      }
     }
   }
 }

+ 21 - 16
resource/js/app.js

@@ -7,7 +7,7 @@ import CrowiRenderer from './util/CrowiRenderer';
 import HeaderSearchBox  from './components/HeaderSearchBox';
 import SearchPage       from './components/SearchPage';
 import PageEditor       from './components/PageEditor';
-import ThemeSelector    from './components/PageEditor/ThemeSelector';
+import EditorOptionsSelector from './components/PageEditor/EditorOptionsSelector';
 import PageListSearch   from './components/PageListSearch';
 import PageHistory      from './components/PageHistory';
 import PageComments     from './components/PageComments';
@@ -17,6 +17,8 @@ import SeenUserList     from './components/SeenUserList';
 import RevisionPath     from './components/Page/RevisionPath';
 import RevisionUrl      from './components/Page/RevisionUrl';
 import BookmarkButton   from './components/BookmarkButton';
+import NewPageNameInputter from './components/NewPageNameInputter';
+import SearchTypeahead  from './components/SearchTypeahead';
 
 import CustomCssEditor  from './components/Admin/CustomCssEditor';
 import CustomScriptEditor from './components/Admin/CustomScriptEditor';
@@ -44,6 +46,7 @@ if (mainContent !== null) {
     pageContent = rawText.innerHTML;
   }
 }
+const isLoggedin = document.querySelector('.main-container.nologin') == null;
 
 // FIXME
 const crowi = new Crowi({
@@ -52,7 +55,9 @@ const crowi = new Crowi({
 }, window);
 window.crowi = crowi;
 crowi.setConfig(JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}'));
-crowi.fetchUsers();
+if (isLoggedin) {
+  crowi.fetchUsers();
+}
 
 const crowiRenderer = new CrowiRenderer(crowi);
 window.crowiRenderer = crowiRenderer;
@@ -84,6 +89,9 @@ const componentMappings = {
   //'revision-history': <PageHistory pageId={pageId} />,
   'seen-user-list': <SeenUserList pageId={pageId} crowi={crowi} />,
   'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
+
+  'page-name-inputter': <NewPageNameInputter crowi={crowi} parentPageName={pagePath} />,
+
 };
 // additional definitions if pagePath exists
 if (pagePath) {
@@ -109,28 +117,28 @@ if (elem) {
  * PageEditor
  */
 let pageEditor = null;
-// load editorTheme
-const editorTheme = crowi.loadEditorTheme();
 // render PageEditor
 const pageEditorElem = document.getElementById('page-editor');
 if (pageEditorElem) {
   pageEditor = ReactDOM.render(
     <PageEditor crowi={crowi} pageId={pageId} revisionId={pageRevisionId} pagePath={pagePath}
-        markdown={entities.decodeHTML(pageContent)} editorTheme={editorTheme}
+        markdown={entities.decodeHTML(pageContent)} editorOptions={crowi.editorOptions}
         onSaveSuccess={onSaveSuccess} />,
     pageEditorElem
   );
+  // set refs for pageEditor
+  crowi.setPageEditor(pageEditor);
 }
-// render ThemeSelector
-const themeSelectorElem = document.getElementById('page-editor-theme-selector');
-if (themeSelectorElem) {
+// render EditorOptionsSelector
+const editorOptionSelectorElem = document.getElementById('page-editor-options-selector');
+if (editorOptionSelectorElem) {
   ReactDOM.render(
-    <ThemeSelector value={editorTheme}
-        onChange={(value) => { // set onChange event handler
-          pageEditor.setEditorTheme(value);
-          crowi.saveEditorTheme(value);
+    <EditorOptionsSelector options={crowi.editorOptions}
+        onChange={(opts) => { // set onChange event handler
+          pageEditor.setEditorOptions(opts);
+          crowi.saveEditorOptions(opts);
         }} />,
-    themeSelectorElem
+    editorOptionSelectorElem
   );
 }
 
@@ -160,6 +168,3 @@ if (customScriptEditorElem != null) {
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', function() {
   ReactDOM.render(<PageHistory pageId={pageId} crowi={crowi} />, document.getElementById('revision-history'));
 });
-
-// set refs for pageEditor
-crowi.setPageEditor(componentInstances['page-editor']);

+ 10 - 83
resource/js/components/HeaderSearchBox/SearchForm.js

@@ -3,7 +3,7 @@ import FormGroup from 'react-bootstrap/es/FormGroup';
 import Button from 'react-bootstrap/es/Button';
 import InputGroup from 'react-bootstrap/es/InputGroup';
 
-import { AsyncTypeahead } from 'react-bootstrap-typeahead';
+import SearchTypeahead from '../SearchTypeahead';
 
 import UserPicture from '../User/UserPicture';
 import PageListMeta from '../PageList/PageListMeta';
@@ -19,19 +19,10 @@ export default class SearchForm extends React.Component {
     this.crowi = window.crowi; // FIXME
 
     this.state = {
-      input: '',
-      keyword: '',
-      searchedKeyword: '',
-      pages: [],
-      isLoading: false,
       searchError: null,
     };
 
-    this.search = this.search.bind(this);
-    this.clearForm = this.clearForm.bind(this);
-    this.getFormClearComponent = this.getFormClearComponent.bind(this);
-    this.renderMenuItemChildren = this.renderMenuItemChildren.bind(this);
-    this.onInputChange = this.onInputChange.bind(this);
+    this.onSearchError = this.onSearchError.bind(this);
     this.onChange = this.onChange.bind(this);
   }
 
@@ -41,51 +32,10 @@ export default class SearchForm extends React.Component {
   componentWillUnmount() {
   }
 
-  search(keyword) {
-
-    if (keyword === '') {
-      this.setState({
-        keyword: '',
-        searchedKeyword: '',
-      });
-      return;
-    }
-
-    this.setState({isLoading: true});
-
-    this.crowi.apiGet('/search', {q: keyword})
-      .then(res => {
-        this.setState({
-          isLoading: false,
-          keyword: '',
-          pages: res.data,
-        });
-      })
-      .catch(err => {
-        this.setState({
-          isLoading: false,
-          searchError: err,
-        });
-      });
-  }
-
-  getFormClearComponent() {
-    let isHidden = (this.state.input.length === 0);
-
-    return isHidden ? <span></span> : (
-      <a className="btn btn-link search-top-clear" onClick={this.clearForm} hidden={isHidden}>
-        <i className="fa fa-times-circle" />
-      </a>
-    );
-  }
-
-  clearForm() {
-    this._typeahead.getInstance().clear();
-    this.setState({keyword: ''});
-  }
-
-  onInputChange(text) {
-    this.setState({input: text});
+  onSearchError(err) {
+    this.setState({
+      searchError: err,
+    });
   }
 
   onChange(selected) {
@@ -97,22 +47,10 @@ export default class SearchForm extends React.Component {
     }
   }
 
-  renderMenuItemChildren(option, props, index) {
-    const page = option;
-    return (
-      <span>
-        <UserPicture user={page.revision.author} />
-        <PagePath page={page} />
-        <PageListMeta page={page} />
-      </span>
-    );
-  }
-
   render() {
     const emptyLabel = (this.state.searchError !== null)
         ? 'Error on searching.'
         : 'No matches found on title... Hit [Enter] key so that search on contents.';
-    const formClear = this.getFormClearComponent();
 
     return (
       <form
@@ -121,23 +59,12 @@ export default class SearchForm extends React.Component {
       >
         <FormGroup>
           <InputGroup>
-            <AsyncTypeahead
-              ref={ref => this._typeahead = ref}
-              inputProps={{name: "q", autoComplete: "off"}}
-              isLoading={this.state.isLoading}
-              labelKey="path"
-              minLength={2}
-              options={this.state.pages}
-              placeholder="Search ..."
-              emptyLabel={emptyLabel}
-              align='left'
-              submitFormOnEnter={true}
-              onSearch={this.search}
-              onInputChange={this.onInputChange}
+            <SearchTypeahead
+              crowi={this.crowi}
               onChange={this.onChange}
-              renderMenuItemChildren={this.renderMenuItemChildren}
+              emptyLabel={emptyLabel}
+              placeholder="Search ..."
             />
-            {formClear}
             <InputGroup.Button>
               <Button type="submit">
                 <i className="search-top-icon fa fa-search"></i>

+ 73 - 0
resource/js/components/NewPageNameInputter.js

@@ -0,0 +1,73 @@
+import React from 'react';
+import { FormGroup, Button, InputGroup } from 'react-bootstrap';
+
+import UserPicture from './User/UserPicture';
+import PageListMeta from './PageList/PageListMeta';
+import PagePath from './PageList/PagePath';
+import PropTypes from 'prop-types';
+import SearchTypeahead from './SearchTypeahead';
+
+export default class NewPageNameInputter extends React.Component {
+
+  constructor(props) {
+
+    super(props);
+
+    this.state = {
+      searchError: null,
+    };
+    this.crowi = this.props.crowi;
+
+    this.onSearchError = this.onSearchError.bind(this);
+    this.getParentPageName = this.getParentPageName.bind(this);
+  }
+
+  componentDidMount() {
+  }
+
+  componentWillUnmount() {
+  }
+
+  onSearchError(err) {
+    this.setState({
+      searchError: err,
+    });
+  }
+
+  getParentPageName(path) {
+    if (path == '/') {
+      return path;
+    }
+
+    if (path.match(/.+\/$/)) {
+      return path;
+    }
+
+    return path + '/';
+  }
+
+  render() {
+    const emptyLabel = (this.state.searchError !== null)
+      ? 'Error on searching.'
+      : 'No matches found on title...';
+
+    return (
+      <SearchTypeahead
+        crowi={this.crowi}
+        onSearchError={this.onSearchError}
+        emptyLabel={emptyLabel}
+        placeholder="Input page name"
+        keywordOnInit={this.getParentPageName(this.props.parentPageName)}
+      />
+    );
+  }
+}
+
+NewPageNameInputter.propTypes = {
+  crowi:          PropTypes.object.isRequired,
+  parentPageName: PropTypes.string,
+};
+
+NewPageNameInputter.defaultProps = {
+  parentPageName: '',
+};

+ 7 - 7
resource/js/components/PageEditor.js

@@ -21,7 +21,7 @@ export default class PageEditor extends React.Component {
       markdown: this.props.markdown,
       isUploadable,
       isUploadableFile,
-      editorTheme: this.props.editorTheme,
+      editorOptions: this.props.editorOptions,
     };
 
     this.setCaretLine = this.setCaretLine.bind(this);
@@ -62,11 +62,11 @@ export default class PageEditor extends React.Component {
   }
 
   /**
-   * set theme (used from the outside)
-   * @param {string} theme theme name
+   * set options (used from the outside)
+   * @param {object} editorOptions
    */
-  setEditorTheme(theme) {
-    this.setState({ editorTheme: theme });
+  setEditorOptions(editorOptions) {
+    this.setState({ editorOptions });
   }
 
   /**
@@ -287,9 +287,9 @@ export default class PageEditor extends React.Component {
       <div className="row">
         <div className="col-md-6 col-sm-12 page-editor-editor-container">
           <Editor ref="editor" value={this.state.markdown}
+              editorOptions={this.state.editorOptions}
               isUploadable={this.state.isUploadable}
               isUploadableFile={this.state.isUploadableFile}
-              theme={this.state.editorTheme}
               onScroll={this.onEditorScroll}
               onChange={this.onMarkdownChanged}
               onSave={this.onSave}
@@ -311,5 +311,5 @@ PageEditor.propTypes = {
   revisionId: PropTypes.string,
   pagePath: PropTypes.string,
   onSaveSuccess: PropTypes.func,
-  editorTheme: PropTypes.string,
+  editorOptions: PropTypes.object,
 };

+ 14 - 3
resource/js/components/PageEditor/Editor.js

@@ -14,6 +14,7 @@ require('codemirror/addon/hint/show-hint');
 require('codemirror/addon/hint/show-hint.css');
 require('codemirror/addon/search/searchcursor');
 require('codemirror/addon/search/match-highlighter');
+require('codemirror/addon/selection/active-line');
 require('codemirror/addon/scroll/annotatescrollbar');
 require('codemirror/addon/fold/foldcode');
 require('codemirror/addon/fold/foldgutter');
@@ -94,7 +95,14 @@ export default class Editor extends React.Component {
    */
   setCaretLine(line) {
     const editor = this.getCodeMirror();
-    editor.setCursor({line: line-1});   // leave 'ch' field as null/undefined to indicate the end of line
+
+    // scroll to the bottom for a moment
+    const eol = editor.getDoc().lineCount() - 1;
+    editor.scrollIntoView(eol);
+
+    const linePosition = Math.max(0, line - 1);
+    editor.scrollIntoView(linePosition);
+    editor.setCursor({line: linePosition});   // leave 'ch' field as null/undefined to indicate the end of line
   }
 
   /**
@@ -263,7 +271,8 @@ export default class Editor extends React.Component {
       height: 'calc(100% - 20px)'
     }
 
-    const theme = this.props.theme || 'elegant';
+    const theme = this.props.editorOptions.theme || 'elegant';
+    const styleActiveLine = this.props.editorOptions.styleActiveLine || undefined;
     return (
       <div style={flexContainer}>
         <Dropzone
@@ -291,6 +300,7 @@ export default class Editor extends React.Component {
             options={{
               mode: 'gfm',
               theme: theme,
+              styleActiveLine: styleActiveLine,
               lineNumbers: true,
               tabSize: 4,
               indentUnit: 4,
@@ -347,7 +357,7 @@ export default class Editor extends React.Component {
 
 Editor.propTypes = {
   value: PropTypes.string,
-  theme: PropTypes.string,
+  options: PropTypes.object,
   isUploadable: PropTypes.bool,
   isUploadableFile: PropTypes.bool,
   onChange: PropTypes.func,
@@ -355,3 +365,4 @@ Editor.propTypes = {
   onSave: PropTypes.func,
   onUpload: PropTypes.func,
 };
+

+ 99 - 0
resource/js/components/PageEditor/EditorOptionsSelector.js

@@ -0,0 +1,99 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import FormGroup from 'react-bootstrap/es/FormGroup';
+import FormControl from 'react-bootstrap/es/FormControl';
+import ControlLabel from 'react-bootstrap/es/ControlLabel';
+import Button from 'react-bootstrap/es/Button';
+
+export default class EditorOptionsSelector extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      options: this.props.options,
+    }
+
+    this.availableThemes = [
+      'elegant', 'neo', 'mdn-like', 'material', 'monokai', 'twilight'
+    ]
+
+    this.onChangeTheme = this.onChangeTheme.bind(this);
+    this.onClickStyleActiveLine = this.onClickStyleActiveLine.bind(this);
+  }
+
+  componentDidMount() {
+    this.init();
+  }
+
+  init() {
+    this.themeSelectorInputEl.value = this.state.options.theme || this.availableThemes[0];
+  }
+
+  onChangeTheme() {
+    const newValue = this.themeSelectorInputEl.value;
+    const newOpts = Object.assign(this.state.options, {theme: newValue});
+    this.setState({options: newOpts});
+
+    // dispatch event
+    this.dispatchOnChange();
+  }
+
+  onClickStyleActiveLine(event) {
+    const newValue = !this.state.options.styleActiveLine;
+    console.log(newValue);
+    const newOpts = Object.assign(this.state.options, {styleActiveLine: newValue});
+    this.setState({options: newOpts});
+
+    // dispatch event
+    this.dispatchOnChange();
+  }
+
+  dispatchOnChange() {
+    if (this.props.onChange != null) {
+      this.props.onChange(this.state.options);
+    }
+  }
+
+  renderThemeSelector() {
+    const optionElems = this.availableThemes.map((theme) => {
+      return <option key={theme} value={theme}>{theme}</option>;
+    });
+
+    return (
+      <FormGroup controlId="formControlsSelect">
+        <ControlLabel>Theme:</ControlLabel>
+        <FormControl componentClass="select" placeholder="select"
+            onChange={this.onChangeTheme}
+            inputRef={ el => this.themeSelectorInputEl=el }>
+
+          {optionElems}
+
+        </FormControl>
+      </FormGroup>
+    )
+  }
+
+  renderStyleActiveLineSelector() {
+    const bool = this.state.options.styleActiveLine || false;
+    return (
+      <FormGroup controlId="formControlsSelect">
+        <Button active={bool} className="btn-style-active-line"
+            onClick={this.onClickStyleActiveLine}
+            ref="styleActiveLineButton">
+          Active Line
+        </Button>
+      </FormGroup>
+    )
+  }
+
+  render() {
+    return <span>{this.renderThemeSelector()} {this.renderStyleActiveLineSelector()}</span>
+  }
+}
+
+EditorOptionsSelector.propTypes = {
+  options: PropTypes.object,
+  onChange: PropTypes.func,
+};

+ 0 - 57
resource/js/components/PageEditor/ThemeSelector.js

@@ -1,57 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import FormGroup from 'react-bootstrap/es/FormGroup';
-import FormControl from 'react-bootstrap/es/FormControl';
-import ControlLabel from 'react-bootstrap/es/ControlLabel';
-
-export default class ThemeSelector extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.availableThemes = [
-      'elegant', 'neo', 'mdn-like', 'material', 'monokai', 'twilight'
-    ]
-    
-    this.onChange = this.onChange.bind(this);
-  }
-
-  componentDidMount() {
-    this.init(this.props.value || this.availableThemes[0]);
-  }
-
-  init(value) {
-    this.inputEl.value = value;
-  }
-
-  onChange() {
-    if (this.props.onChange != null) {
-      this.props.onChange(this.inputEl.value);
-    }
-  }
-
-  render() {
-    const options = this.availableThemes.map((theme) => {
-      return <option key={theme} value={theme}>{theme}</option>;
-    });
-
-    return (
-      <FormGroup controlId="formControlsSelect" bsClass="theme-selector">
-        <ControlLabel>Theme:</ControlLabel>
-        <FormControl componentClass="select" placeholder="select"
-            onChange={this.onChange}
-            inputRef={ el => this.inputEl=el }>
-
-          {options}
-
-        </FormControl>
-      </FormGroup>
-    )
-  }
-}
-
-ThemeSelector.propTypes = {
-  value: PropTypes.string,
-  onChange: PropTypes.func,
-};

+ 1 - 5
resource/js/components/PageHistory.js

@@ -82,11 +82,7 @@ export default class PageHistory extends React.Component {
     const diffOpened = this.state.diffOpened,
       revisionId = revision._id;
 
-    if (diffOpened[revisionId]) {
-      return ;
-    }
-
-    diffOpened[revisionId] = true;
+    diffOpened[revisionId] = !(diffOpened[revisionId]);
     this.setState({
       diffOpened
     });

+ 1 - 0
resource/js/components/PageHistory/PageRevisionList.js

@@ -26,6 +26,7 @@ export default class PageRevisionList extends React.Component {
         <div className="revision-hisory-outer" key={"revision-history-" + revisionId}>
           <Revision
             revision={revision}
+            revisionDiffOpened={revisionDiffOpened}
             onDiffOpenClicked={this.props.onDiffOpenClicked}
             key={"revision-history-rev-" + revisionId}
             />

+ 6 - 4
resource/js/components/PageHistory/Revision.js

@@ -29,6 +29,7 @@ export default class Revision extends React.Component {
       pic = <UserPicture user={author} />;
     }
 
+    const iconName = this.props.revisionDiffOpened ? 'caret-down' : 'caret-right';
     return (
       <div className="revision-history-main">
         {pic}
@@ -40,11 +41,11 @@ export default class Revision extends React.Component {
             <UserDate dateTime={revision.createdAt} />
           </p>
           <p>
-            <a href={"?revision=" + revision._id }>
-              <Icon name="history" /> View this version
-            </a>
             <a className="diff-view" onClick={this._onDiffOpenClicked}>
-              <Icon name="level-down" /> View diff
+              <Icon name={iconName} /> View diff
+            </a>
+            <a href={"?revision=" + revision._id }>
+              <Icon name="sign-in" /> Go to this version
             </a>
           </p>
         </div>
@@ -55,6 +56,7 @@ export default class Revision extends React.Component {
 
 Revision.propTypes = {
   revision: PropTypes.object,
+  revisionDiffOpened: PropTypes.bool.isRequired,
   onDiffOpenClicked: PropTypes.func.isRequired,
 }
 

+ 194 - 0
resource/js/components/SearchTypeahead.js

@@ -0,0 +1,194 @@
+import {noop} from 'lodash';
+import React from 'react';
+
+import { AsyncTypeahead } from 'react-bootstrap-typeahead';
+
+import UserPicture from './User/UserPicture';
+import PageListMeta from './PageList/PageListMeta';
+import PagePath from './PageList/PagePath';
+import PropTypes from 'prop-types';
+
+export default class SearchTypeahead extends React.Component {
+
+  constructor(props) {
+
+    super(props);
+
+    this.state = {
+      input: '',
+      keyword: '',
+      searchedKeyword: '',
+      pages: [],
+      isLoading: false,
+      searchError: null,
+    };
+    this.crowi = this.props.crowi;
+    this.emptyLabel = props.emptyLabel;
+
+    this.search = this.search.bind(this);
+    this.onInputChange = this.onInputChange.bind(this);
+    this.onChange = this.onChange.bind(this);
+    this.getRestoreFormButton = this.getRestoreFormButton.bind(this);
+    this.renderMenuItemChildren = this.renderMenuItemChildren.bind(this);
+    this.restoreInitialData = this.restoreInitialData.bind(this);
+    this.getTypeahead = this.getTypeahead.bind(this);
+  }
+
+  /**
+   * Get instance of AsyncTypeahead
+   */
+  getTypeahead() {
+    return this.refs.typeahead ? this.refs.typeahead.getInstance() : null;
+  }
+
+  componentDidMount() {
+  }
+
+  componentWillUnmount() {
+  }
+
+  search(keyword) {
+
+    if (keyword === '') {
+      this.setState({
+        keyword: '',
+        searchedKeyword: '',
+      });
+      return;
+    }
+
+    this.setState({isLoading: true});
+
+    this.crowi.apiGet('/search', {q: keyword})
+      .then(res => { this.onSearchSuccess(res) })
+      .catch(err => { this.onSearchError(err) });
+  }
+
+  /**
+   * Callback function which is occured when search is exit successfully
+   * @param {*} pages
+   */
+  onSearchSuccess(res) {
+    this.setState({
+      isLoading: false,
+      keyword: '',
+      pages: res.data,
+    });
+    this.props.onSearchSuccess && this.props.onSearchSuccess(res);
+  }
+
+  /**
+   * Callback function which is occured when search is exit abnormaly
+   * @param {*} err
+   */
+  onSearchError(err) {
+    this.setState({
+      isLoading: false,
+      searchError: err,
+    });
+    this.props.onSearchError && this.props.onSearchError(err);
+  }
+
+  onInputChange(text) {
+    this.setState({input: text});
+  }
+
+  onChange(selected) {
+    const page = selected[0];  // should be single page selected
+
+    // navigate to page
+    if (page != null) {
+        window.location = page.path;
+    }
+  }
+
+  renderMenuItemChildren(option, props, index) {
+    const page = option;
+    return (
+      <span>
+      <UserPicture user={page.revision.author} />
+      <PagePath page={page} />
+      <PageListMeta page={page} />
+      </span>
+    );
+  }
+
+  /**
+   * Initialize keyword
+   */
+  restoreInitialData() {
+    this.refs.typeahead.getInstance().clear();
+    this.refs.typeahead.getInstance()._updateText(this.props.keywordOnInit);
+  }
+
+  /**
+   * Get restore form button to initialize button
+   */
+  getRestoreFormButton() {
+    let isHidden = (this.state.input.length === 0);
+
+    return isHidden ? <span></span> : (
+      <a className="btn btn-link search-top-clear" onClick={this.restoreInitialData} hidden={isHidden}>
+        <i className="fa fa-times-circle" />
+      </a>
+    );
+  }
+
+  render() {
+    const emptyLabel = (this.state.searchError !== null)
+      ? 'Error on searching.'
+      : 'No matches found on title...';
+    const restoreFormButton = this.getRestoreFormButton();
+    const defaultSelected = (this.props.keywordOnInit != "")
+      ? [{path: this.props.keywordOnInit}]
+      : [];
+
+    return (
+      <span>
+        <AsyncTypeahead
+          {...this.props}
+          ref="typeahead"
+          inputProps={{name: "q", autoComplete: "off"}}
+          isLoading={this.state.isLoading}
+          labelKey="path"
+          minLength={2}
+          options={this.state.pages} // Search result (Some page names)
+          emptyLabel={this.emptyLabel ? this.emptyLabel : emptyLabel}
+          align='left'
+          submitFormOnEnter={true}
+          onSearch={this.search}
+          onInputChange={this.onInputChange}
+          renderMenuItemChildren={this.renderMenuItemChildren}
+          caseSensitive={false}
+          defaultSelected={defaultSelected}
+        />
+        {restoreFormButton}
+      </span>
+    );
+  }
+}
+
+/**
+ * Properties
+ */
+SearchTypeahead.propTypes = {
+  crowi:           PropTypes.object.isRequired,
+  onSearchSuccess: PropTypes.func,
+  onSearchError:   PropTypes.func,
+  onChange:        PropTypes.func,
+  emptyLabel:      PropTypes.string,
+  placeholder:     PropTypes.string,
+  keywordOnInit:   PropTypes.string,
+};
+
+/**
+ * Properties
+ */
+SearchTypeahead.defaultProps = {
+  onSearchSuccess: noop,
+  onSearchError:   noop,
+  onChange:        noop,
+  emptyLabel:      null,
+  placeholder:     "",
+  keywordOnInit:   "",
+};

+ 11 - 3
resource/js/legacy/crowi.js

@@ -902,6 +902,8 @@ window.addEventListener('load', function(e) {
   if (location.hash) {
     if (location.hash == '#edit-form') {
       $('a[data-toggle="tab"][href="#edit-form"]').tab('show');
+      // focus
+      Crowi.setCaretLineAndFocusToEditor();
     }
     if (location.hash == '#revision-history') {
       $('a[data-toggle="tab"][href="#revision-history"]').tab('show');
@@ -943,7 +945,6 @@ window.addEventListener('load', function(e) {
   Crowi.highlightSelectedSection(location.hash);
   Crowi.modifyScrollTop();
   Crowi.setCaretLineAndFocusToEditor();
-  Crowi.setCaretLineAndFocusToEditor();
 });
 
 window.addEventListener('hashchange', function(e) {
@@ -966,9 +967,16 @@ window.addEventListener('hashchange', function(e) {
 });
 
 window.addEventListener('keypress', (event) => {
+  const target = event.target;
+
   // ignore when target dom is input
-  const inputPattern = /input|textinput|textarea/i;
-  if (event.target.tagName.match(inputPattern)) {
+  const inputPattern = /^input|textinput|textarea$/i;
+  if (target.tagName.match(inputPattern)) {
+    return;
+  }
+
+  // ignore when dom that has 'modal in' classes exists
+  if (document.getElementsByClassName('modal in').length > 0) {
     return;
   }
 

+ 4 - 6
resource/js/util/Crowi.js

@@ -39,6 +39,7 @@ export default class Crowi {
     this.userByName = {};
     this.userById   = {};
     this.draft = {};
+    this.editorOptions = {};
 
     this.recoverData();
   }
@@ -72,6 +73,7 @@ export default class Crowi {
       'userById',
       'users',
       'draft',
+      'editorOptions',
     ];
 
     keys.forEach(key => {
@@ -147,12 +149,8 @@ export default class Crowi {
     return null;
   }
 
-  saveEditorTheme(theme) {
-    this.localStorage.setItem('editorTheme', theme);
-  }
-
-  loadEditorTheme() {
-    return this.localStorage.getItem('editorTheme');
+  saveEditorOptions(options) {
+    this.localStorage.setItem('editorOptions', JSON.stringify(options));
   }
 
   findUserById(userId) {

+ 16 - 9
yarn.lock

@@ -122,7 +122,7 @@ agentkeepalive@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-2.2.0.tgz#c5d1bd4b129008f1163f236f86e5faea2026e2ef"
 
-ajv-keywords@^2.0.0:
+ajv-keywords@^2.0.0, ajv-keywords@^2.1.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-2.1.1.tgz#617997fc5f60576894c435f940d819e135b80762"
 
@@ -5028,9 +5028,9 @@ react-clipboard.js@^1.1.2:
     clipboard "^1.6.1"
     prop-types "^15.5.0"
 
-react-codemirror2@^3.0.7:
-  version "3.0.7"
-  resolved "https://registry.yarnpkg.com/react-codemirror2/-/react-codemirror2-3.0.7.tgz#d5d9888158263ae56da766539d7803486566ab9f"
+react-codemirror2@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/react-codemirror2/-/react-codemirror2-4.0.0.tgz#88a30bb082cb87755a80e057d4c7b577456c38f0"
 
 react-dom@^16.0.0:
   version "16.2.0"
@@ -5483,6 +5483,13 @@ schema-utils@^0.3.0:
   dependencies:
     ajv "^5.0.0"
 
+schema-utils@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.4.3.tgz#e2a594d3395834d5e15da22b48be13517859458e"
+  dependencies:
+    ajv "^5.0.0"
+    ajv-keywords "^2.1.0"
+
 scss-tokenizer@^0.2.3:
   version "0.2.3"
   resolved "https://registry.yarnpkg.com/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz#8eb06db9a9723333824d3f5530641149847ce5d1"
@@ -5852,12 +5859,12 @@ strip-json-comments@~2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
 
-style-loader@^0.19.0:
-  version "0.19.1"
-  resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.19.1.tgz#591ffc80bcefe268b77c5d9ebc0505d772619f85"
+style-loader@^0.20.1:
+  version "0.20.1"
+  resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.20.1.tgz#33ac2bf4d5c65a8906bc586ad253334c246998d0"
   dependencies:
-    loader-utils "^1.0.2"
-    schema-utils "^0.3.0"
+    loader-utils "^1.1.0"
+    schema-utils "^0.4.3"
 
 supports-color@4.4.0:
   version "4.4.0"