Sfoglia il codice sorgente

Merge branch 'master' into imprv/replace-renderer

Yuki Takei 8 anni fa
parent
commit
6ec7d35b7f

+ 13 - 1
CHANGES.md

@@ -1,10 +1,22 @@
 CHANGES
 ========
 
-## 2.3.8-RC
+## 2.3.9-RC
 
+* Fix: `Ctrl-/` doesn't work on Chrome
+* Fix: Close Shortcuts help with `Ctrl-/`, ESC key
+* Fix: Jump to last line wrongly when `.revision-head-edit-button` clicked
+
+## 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
 

+ 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>

+ 1 - 1
lib/views/modal/shortcuts.html

@@ -1,4 +1,4 @@
-<div class="modal" id="shortcuts-modal">
+<div class="modal" id="shortcuts-modal" tabindex="-1">
   <div class="modal-dialog">
     <div class="modal-content">
 

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "crowi-plus",
-  "version": "2.3.8-RC",
+  "version": "2.3.9-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",

+ 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

@@ -8,7 +8,7 @@ import GrowiRenderer from './util/GrowiRenderer';
 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';
@@ -18,6 +18,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';
@@ -45,6 +47,7 @@ if (mainContent !== null) {
     pageContent = rawText.innerHTML;
   }
 }
+const isLoggedin = document.querySelector('.main-container.nologin') == null;
 
 // FIXME
 const crowi = new Crowi({
@@ -53,7 +56,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 GrowiRenderer(crowi);
 window.crowiRenderer = crowiRenderer;
@@ -85,6 +90,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) {
@@ -110,28 +118,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
   );
 }
 
@@ -161,6 +169,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 });
   }
 
   /**
@@ -279,9 +279,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}
@@ -303,5 +303,5 @@ PageEditor.propTypes = {
   revisionId: PropTypes.string,
   pagePath: PropTypes.string,
   onSaveSuccess: PropTypes.func,
-  editorTheme: PropTypes.string,
+  editorOptions: PropTypes.object,
 };

+ 16 - 4
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');
@@ -33,6 +34,7 @@ require('codemirror/theme/twilight.css');
 import Dropzone from 'react-dropzone';
 
 import pasteHelper from './PasteHelper';
+import markdownListHelper from './MarkdownListHelper';
 import emojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
 
 
@@ -94,7 +96,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 lastLine = editor.getDoc().lastLine();
+    editor.scrollIntoView(lastLine);
+
+    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 +272,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 +301,7 @@ export default class Editor extends React.Component {
             options={{
               mode: 'gfm',
               theme: theme,
+              styleActiveLine: styleActiveLine,
               lineNumbers: true,
               tabSize: 4,
               indentUnit: 4,
@@ -308,7 +319,7 @@ export default class Editor extends React.Component {
               highlightFormatting: true,
               // continuelist, indentlist
               extraKeys: {
-                "Enter": "newlineAndIndentContinueMarkdownList",
+                "Enter": markdownListHelper.newlineAndIndentContinueMarkdownList,
                 "Tab": "indentMore",
                 "Shift-Tab": "indentLess",
                 "Ctrl-Q": (cm) => { cm.foldCode(cm.getCursor()) },
@@ -347,7 +358,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 +366,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,
+};

+ 167 - 0
resource/js/components/PageEditor/MarkdownListHelper.js

@@ -0,0 +1,167 @@
+import * as codemirror from 'codemirror';
+
+class MarkdownListHelper {
+
+  constructor() {
+    // https://github.com/codemirror/CodeMirror/blob/c7853a989c77bb9f520c9c530cbe1497856e96fc/addon/edit/continuelist.js#L14
+    // https://regex101.com/r/7BN2fR/5
+    this.indentAndMarkRE = /^(\s*)(>[> ]*|[*+-] \[[x ]\]\s|[*+-]\s|(\d+)([.)]))(\s*)/;
+    this.indentAndMarkOnlyRE = /^(\s*)(>[> ]*|[*+-] \[[x ]\]|[*+-]|(\d+)[.)])(\s*)$/;
+
+    this.newlineAndIndentContinueMarkdownList = this.newlineAndIndentContinueMarkdownList.bind(this);
+    this.pasteText = this.pasteText.bind(this);
+
+    this.getBol = this.getBol.bind(this);
+    this.getEol = this.getEol.bind(this);
+    this.getStrFromBol = this.getStrFromBol.bind(this);
+    this.getStrToEol = this.getStrToEol.bind(this);
+  }
+
+  /**
+   * wrap codemirror.commands.newlineAndIndentContinueMarkdownList
+   * @param {any} editor An editor instance of CodeMirror
+   */
+  newlineAndIndentContinueMarkdownList(editor) {
+    // get strings from current position to EOL(end of line) before break the line
+    const strToEol = this.getStrToEol(editor);
+
+    if (this.indentAndMarkRE.test(strToEol)) {
+      codemirror.commands.newlineAndIndent(editor);
+      // replace the line with strToEol (abort auto indent)
+      editor.getDoc().replaceRange(strToEol, this.getBol(editor), this.getEol(editor));
+    }
+    else {
+      codemirror.commands.newlineAndIndentContinueMarkdownList(editor);
+    }
+  }
+
+  /**
+   * paste text
+   * @param {any} editor An editor instance of CodeMirror
+   * @param {any} event
+   * @param {string} text
+   */
+  pasteText(editor, event, text) {
+    // get strings from BOL(beginning of line) to current position
+    const strFromBol = this.getStrFromBol(editor);
+
+    const matched = strFromBol.match(this.indentAndMarkRE);
+    // when match indentAndMarkOnlyRE
+    // (this means the current position is the beginning of the list item)
+    if (this.indentAndMarkOnlyRE.test(strFromBol)) {
+      const adjusted = this.adjustPastedData(strFromBol, text);
+
+      // replace
+      if (adjusted != null) {
+        event.preventDefault();
+        editor.getDoc().replaceRange(adjusted, this.getBol(editor), editor.getCursor());
+      }
+    }
+  }
+
+  /**
+   * return adjusted pasted data by indentAndMark
+   *
+   * @param {string} indentAndMark
+   * @param {string} text
+   * @returns adjusted pasted data
+   *      returns null when adjustment is not necessary
+   */
+  adjustPastedData(indentAndMark, text) {
+    let adjusted = null;
+
+    // list data (starts with indent and mark)
+    if (text.match(this.indentAndMarkRE)) {
+      const indent = indentAndMark.match(this.indentAndMarkRE)[1];
+
+      // splice to an array of line
+      const lines = text.match(/[^\r\n]+/g);
+      // indent
+      const replacedLines = lines.map((line) => {
+        return indent + line;
+      })
+
+      adjusted = replacedLines.join('\n');
+    }
+    // listful data
+    else if (this.isListfulData(text)) {
+      // do nothing (return null)
+    }
+    // not listful data
+    else {
+      // append `indentAndMark` at the beginning of all lines (except the first line)
+      const replacedText = text.replace(/(\r\n|\r|\n)/g, "$1" + indentAndMark);
+      // append `indentAndMark` to the first line
+      adjusted = indentAndMark + replacedText;
+    }
+
+    return adjusted;
+  }
+
+  /**
+   * evaluate whether `text` is list like data or not
+   * @param {string} text
+   */
+  isListfulData(text) {
+    // return false if includes at least one blank line
+    // see https://stackoverflow.com/a/16369725
+    if (text.match(/^\s*[\r\n]/m) != null) {
+      return false;
+    }
+
+    const lines = text.match(/[^\r\n]+/g);
+    // count lines that starts with indent and mark
+    let isListful = false;
+    let count = 0;
+    lines.forEach((line) => {
+      if (line.match(this.indentAndMarkRE)) {
+        count++;
+      }
+      // ensure to be true if it is 50% or more
+      if (count >= lines.length / 2) {
+        isListful = true;
+        return;
+      }
+    });
+
+    return isListful;
+  }
+
+  /**
+   * return the postion of the BOL(beginning of line)
+   */
+  getBol(editor) {
+    const curPos = editor.getCursor();
+    return { line: curPos.line, ch: 0 };
+  }
+
+  /**
+   * return the postion of the EOL(end of line)
+   */
+  getEol(editor) {
+    const curPos = editor.getCursor();
+    const lineLength = editor.getDoc().getLine(curPos.line).length;
+    return { line: curPos.line, ch: lineLength };
+  }
+
+  /**
+   * return strings from BOL(beginning of line) to current position
+   */
+  getStrFromBol(editor) {
+    const curPos = editor.getCursor();
+    return editor.getDoc().getRange(this.getBol(editor), curPos);
+  }
+
+  /**
+   * return strings from current position to EOL(end of line)
+   */
+  getStrToEol(editor) {
+    const curPos = editor.getCursor();
+    return editor.getDoc().getRange(curPos, this.getEol(editor));
+  }
+}
+
+// singleton pattern
+const instance = new MarkdownListHelper();
+Object.freeze(instance);
+export default instance;

+ 5 - 91
resource/js/components/PageEditor/PasteHelper.js

@@ -1,13 +1,11 @@
 import accepts from 'attr-accept'
 
+import markdownListHelper from './MarkdownListHelper';
+
 class PasteHelper {
 
   constructor() {
-    // https://regex101.com/r/7BN2fR/3
-    this.indentAndMarkPattern = /^([ \t]*)(?:>|\-|\+|\*|\d+\.) /;
-
     this.pasteText = this.pasteText.bind(this);
-    this.adjustPastedData = this.adjustPastedData.bind(this);
   }
 
   /**
@@ -19,97 +17,13 @@ class PasteHelper {
     // get data in clipboard
     const text = event.clipboardData.getData('text/plain');
 
-    if (text.length == 0) { return; }
-
-    const curPos = editor.getCursor();
-    const bol = { line: curPos.line, ch: 0 }; // beginning of line
-
-    // get strings from BOL(beginning of line) to current position
-    const strFromBol = editor.getDoc().getRange(bol, curPos);
-
-    const matched = strFromBol.match(this.indentAndMarkPattern);
-    // when match completely to pattern
-    // (this means the current position is the beginning of the list item)
-    if (matched && matched[0] == strFromBol) {
-      const adjusted = this.adjustPastedData(strFromBol, text);
-
-      // replace
-      if (adjusted != null) {
-        event.preventDefault();
-        editor.getDoc().replaceRange(adjusted, bol, curPos);
-      }
+    if (text.length == 0) {
+      return;
     }
-  }
-
-  /**
-   * return adjusted pasted data by indentAndMark
-   *
-   * @param {string} indentAndMark
-   * @param {string} text
-   * @returns adjusted pasted data
-   *      returns null when adjustment is not necessary
-   */
-  adjustPastedData(indentAndMark, text) {
-    let adjusted = null;
-
-    // list data (starts with indent and mark)
-    if (text.match(this.indentAndMarkPattern)) {
-      const indent = indentAndMark.match(this.indentAndMarkPattern)[1];
-
-      // splice to an array of line
-      const lines = text.match(/[^\r\n]+/g);
-      // indent
-      const replacedLines = lines.map((line) => {
-        return indent + line;
-      })
 
-      adjusted = replacedLines.join('\n');
-    }
-    // listful data
-    else if (this.isListfulData(text)) {
-      // do nothing (return null)
-    }
-    // not listful data
-    else {
-      // append `indentAndMark` at the beginning of all lines (except the first line)
-      const replacedText = text.replace(/(\r\n|\r|\n)/g, "$1" + indentAndMark);
-      // append `indentAndMark` to the first line
-      adjusted = indentAndMark + replacedText;
-    }
-
-    return adjusted;
-  }
-
-  /**
-   * evaluate whether `text` is list like data or not
-   * @param {string} text
-   */
-  isListfulData(text) {
-    // return false if includes at least one blank line
-    // see https://stackoverflow.com/a/16369725
-    if (text.match(/^\s*[\r\n]/m) != null) {
-      return false;
-    }
-
-    const lines = text.match(/[^\r\n]+/g);
-    // count lines that starts with indent and mark
-    let isListful = false;
-    let count = 0;
-    lines.forEach((line) => {
-      if (line.match(this.indentAndMarkPattern)) {
-        count++;
-      }
-      // ensure to be true if it is 50% or more
-      if (count >= lines.length / 2) {
-        isListful = true;
-        return;
-      }
-    });
-
-    return isListful;
+    markdownListHelper.pasteText(editor, event, text);
   }
 
-
   // Firefox versions prior to 53 return a bogus MIME type for every file drag, so dragovers with
   /**
    * transplanted from react-dropzone

+ 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,
-};

+ 3 - 1
resource/js/components/PageList/PagePath.js

@@ -1,6 +1,8 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import * as escapeStringRegexp from 'escape-string-regexp';
+
 export default class PagePath extends React.Component {
 
   getShortPath(path) {
@@ -29,7 +31,7 @@ export default class PagePath extends React.Component {
     const page = this.props.page;
     const pagePath = page.path.replace(this.props.excludePathString.replace(/^\//, ''), '');
     const shortPath = this.getShortPath(pagePath);
-    const shortPathEscaped = shortPath.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
+    const shortPathEscaped = escapeStringRegexp(shortPath);
     const pathPrefix = pagePath.replace(new RegExp(shortPathEscaped + '(/)?$'), '');
 
     return (

+ 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:   "",
+};

+ 21 - 15
resource/js/legacy/crowi.js

@@ -2,9 +2,10 @@
 /* Author: Sotaro KARASAWA <sotarok@crocos.co.jp>
 */
 
-var io = require('socket.io-client');
-var entities = require("entities");
-var getLineFromPos = require('get-line-from-pos');
+const io = require('socket.io-client');
+const entities = require("entities");
+const escapeStringRegexp = require('escape-string-regexp');
+const getLineFromPos = require('get-line-from-pos');
 require('bootstrap-sass');
 require('jquery.cookie');
 
@@ -39,10 +40,11 @@ Crowi.appendEditSectionButtons = function(contentId, markdown) {
   $('h1,h2,h3,h4,h5,h6', $content).each(function(idx, elm) {
     // get header text string
     const text = $(this).text();
+    const escapedText = escapeStringRegexp(text);
 
     // search pos for '# ...'
     // https://regex101.com/r/y5rpO5/1
-    const regexp = new RegExp(`[^\r\n]*#+[^\r\n]*${text}[^\r\n]*`);
+    const regexp = new RegExp(`[^\r\n]*#+[^\r\n]*${escapedText}[^\r\n]*`);
     let position = markdown.search(regexp);
     if (position < 0) { // if not found, search with header text only
       position = markdown.search(text);
@@ -189,18 +191,29 @@ Crowi.updateCurrentRevision = function(revisionId) {
 }
 
 Crowi.handleKeyEHandler = (event) => {
+  // ignore when dom that has 'modal in' classes exists
+  if (document.getElementsByClassName('modal in').length > 0) {
+    return;
+  }
   // show editor
   $('a[data-toggle="tab"][href="#edit-form"]').tab('show');
+  event.preventDefault();
 }
 
 Crowi.handleKeyCHandler = (event) => {
+  // ignore when dom that has 'modal in' classes exists
+  if (document.getElementsByClassName('modal in').length > 0) {
+    return;
+  }
   // show modal to create a page
   $('#create-page').modal();
+  event.preventDefault();
 }
 
 Crowi.handleKeyCtrlSlashHandler = (event) => {
   // show modal to create a page
   $('#shortcuts-modal').modal('toggle');
+  event.preventDefault();
 }
 
 $(function() {
@@ -458,11 +471,8 @@ $(function() {
       return;
     }
 
-    var escape = function(s) {
-      return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
-    };
     path = entities.encodeHTML(path);
-    var pattern = escape(entities.encodeHTML(shortPath)) + '(/)?$';
+    var pattern = escapeStringRegexp(entities.encodeHTML(shortPath)) + '(/)?$';
 
     $link.html(path.replace(new RegExp(pattern), '<strong>' + shortPath + '$1</strong>'));
   });
@@ -894,6 +904,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');
@@ -935,7 +947,6 @@ window.addEventListener('load', function(e) {
   Crowi.highlightSelectedSection(location.hash);
   Crowi.modifyScrollTop();
   Crowi.setCaretLineAndFocusToEditor();
-  Crowi.setCaretLineAndFocusToEditor();
 });
 
 window.addEventListener('hashchange', function(e) {
@@ -957,7 +968,7 @@ window.addEventListener('hashchange', function(e) {
   }
 });
 
-window.addEventListener('keypress', (event) => {
+window.addEventListener('keydown', (event) => {
   const target = event.target;
 
   // ignore when target dom is input
@@ -966,11 +977,6 @@ window.addEventListener('keypress', (event) => {
     return;
   }
 
-  // ignore when dom that has 'modal in' classes exists
-  if (document.getElementsByClassName('modal in').length > 0) {
-    return;
-  }
-
   switch (event.key) {
     case 'e':
       if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {

+ 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) {

+ 3 - 3
yarn.lock

@@ -5048,9 +5048,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"