ソースを参照

Merge pull request #385 from weseek/feat/selectable-keymap

Feat/selectable keymap
Yuki Takei 8 年 前
コミット
78206c238e

+ 14 - 2
.eslintrc.js

@@ -5,7 +5,10 @@ module.exports = {
     "es6": true,
     "node": true
   },
-  "extends": "eslint:recommended",
+  "extends": [
+    "eslint:recommended",
+    "plugin:react/recommended"
+  ],
   "parserOptions": {
     "ecmaFeatures": {
       "experimentalObjectRestSpread": true,
@@ -32,7 +35,11 @@ module.exports = {
     "indent": [
       "error",
       2,
-      { "SwitchCase": 1 }
+      {
+        "SwitchCase": 1,
+        "FunctionExpression": {"parameters": 2},
+        "CallExpression": {"parameters": 2}
+      }
     ],
     "key-spacing": [
       "error", { "beforeColon": false, "afterColon": true }
@@ -48,6 +55,11 @@ module.exports = {
       "error",
       "single"
     ],
+    "react/jsx-indent": [
+      "error",
+      4,
+      { "ignoredNodes": ["JSXElement *"] }
+    ],
     "semi": [
       "error",
       "always"

+ 2 - 0
package.json

@@ -97,6 +97,7 @@
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
+    "load-css-file": "^1.0.0",
     "markdown-it": "^8.4.0",
     "markdown-it-emoji": "^1.4.0",
     "markdown-it-footnote": "^3.0.1",
@@ -136,6 +137,7 @@
     "reveal.js": "^3.5.0",
     "rimraf": "^2.6.1",
     "sass-loader": "^7.0.1",
+    "simple-load-script": "^1.0.2",
     "slack-node": "^0.1.8",
     "socket.io": "^2.0.3",
     "socket.io-client": "^2.0.3",

+ 82 - 9
resource/js/components/PageEditor/Editor.js

@@ -1,6 +1,10 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import urljoin from 'url-join';
+const loadScript = require('simple-load-script');
+const loadCssSync = require('load-css-file');
+
 import * as codemirror from 'codemirror';
 
 import { UnControlled as ReactCodeMirror } from 'react-codemirror2';
@@ -46,8 +50,7 @@ export default class Editor extends React.Component {
   constructor(props) {
     super(props);
 
-    // https://regex101.com/r/7BN2fR/2
-    this.indentAndMarkPattern = /^([ \t]*)(?:>|\-|\+|\*|\d+\.) /;
+    this.cmCdnRoot = 'https://cdn.jsdelivr.net/npm/codemirror@5.37.0';
 
     this.interceptorManager = new InterceptorManager();
     this.interceptorManager.addInterceptors([
@@ -61,6 +64,11 @@ export default class Editor extends React.Component {
       isUploading: false,
     };
 
+    // manage keymap w/o state because 'cm.setOption' is invoked manually
+    this.currentKeymapMode = undefined;
+    this.loadedKeymapSet = new Set();
+
+
     this.getCodeMirror = this.getCodeMirror.bind(this);
     this.setCaretLine = this.setCaretLine.bind(this);
     this.setScrollTopByLine = this.setScrollTopByLine.bind(this);
@@ -80,17 +88,38 @@ export default class Editor extends React.Component {
     this.renderOverlay = this.renderOverlay.bind(this);
   }
 
+
   componentDidMount() {
     // initialize caret line
     this.setCaretLine(0);
     // set save handler
     codemirror.commands.save = this.dispatchSave;
+
+    // set CodeMirror instance as 'CodeMirror' so that CDN addons can reference
+    window.CodeMirror = require('codemirror');
+
+    // apply keymapMode
+    const keymapMode = this.props.editorOptions.keymapMode;
+    this.setKeymapMode(keymapMode);
+  }
+
+  componentWillReceiveProps(nextProps) {
+    // apply keymapMode
+    const keymapMode = nextProps.editorOptions.keymapMode;
+    this.setKeymapMode(keymapMode);
   }
 
   getCodeMirror() {
     return this.refs.cm.editor;
   }
 
+  loadCss(source) {
+    return new Promise((resolve) => {
+      loadCssSync(source);
+      resolve();
+    });
+  }
+
   forceToFocus() {
     const editor = this.getCodeMirror();
     // use setInterval with reluctance -- 2018.01.11 Yuki Takei
@@ -133,6 +162,50 @@ export default class Editor extends React.Component {
     editor.scrollTo(null, top);
   }
 
+  /**
+   * set Key Maps
+   * @see https://codemirror.net/doc/manual.html#keymaps
+   *
+   * @param {string} keymapMode 'vim' or 'emacs' or 'sublime'
+   */
+  setKeymapMode(keymapMode) {
+    const loadCss = this.loadCss;
+
+    if (this.currentKeymapMode === keymapMode) {
+      // do nothing
+      return;
+    }
+    if (keymapMode == null || !keymapMode.match(/^(vim|emacs|sublime)$/)) {
+      // set 'default'
+      this.currentKeymapMode = 'default';
+      this.getCodeMirror().setOption('keyMap', this.currentKeymapMode);
+      return;
+    }
+
+    let scriptList = [];
+    let cssList = [];
+
+    // add dependencies
+    if (this.loadedKeymapSet.size == 0) {
+      scriptList.push(loadScript(urljoin(this.cmCdnRoot, 'addon/dialog/dialog.min.js')));
+      cssList.push(loadCss(urljoin(this.cmCdnRoot, 'addon/dialog/dialog.min.css')));
+    }
+    // load keymap
+    if (!this.loadedKeymapSet.has(keymapMode)) {
+      scriptList.push(loadScript(urljoin(this.cmCdnRoot, `keymap/${keymapMode}.min.js`)));
+      // update Set
+      this.loadedKeymapSet.add(keymapMode);
+    }
+
+    // update fields
+    this.currentKeymapMode = keymapMode;
+
+    Promise.all(scriptList.concat(cssList))
+    .then(() => {
+      this.getCodeMirror().setOption('keyMap', keymapMode);
+    });
+  }
+
   /**
    * remove overlay and set isUploading to false
    */
@@ -321,7 +394,7 @@ export default class Editor extends React.Component {
       height: '100%',
       display: 'flex',
       flexDirection: 'column',
-    }
+    };
 
     const theme = this.props.editorOptions.theme || 'elegant';
     const styleActiveLine = this.props.editorOptions.styleActiveLine || undefined;
@@ -363,17 +436,17 @@ export default class Editor extends React.Component {
               matchTags: {bothTags: true},
               // folding
               foldGutter: true,
-              gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"],
+              gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
               // match-highlighter, matchesonscrollbar, annotatescrollbar options
               highlightSelectionMatches: {annotateScrollbar: true},
               // markdown mode options
               highlightFormatting: true,
               // continuelist, indentlist
               extraKeys: {
-                "Enter": this.handleEnterKey,
-                "Tab": "indentMore",
-                "Shift-Tab": "indentLess",
-                "Ctrl-Q": (cm) => { cm.foldCode(cm.getCursor()) },
+                'Enter': this.handleEnterKey,
+                'Tab': 'indentMore',
+                'Shift-Tab': 'indentLess',
+                'Ctrl-Q': (cm) => { cm.foldCode(cm.getCursor()) },
               }
             }}
             onScroll={(editor, data) => {
@@ -405,7 +478,7 @@ export default class Editor extends React.Component {
           or pasting from the clipboard.
         </button>
       </div>
-    )
+    );
   }
 
 }

+ 42 - 0
resource/js/components/PageEditor/OptionsSelector.js

@@ -30,8 +30,15 @@ export default class OptionsSelector extends React.Component {
     this.availableThemes = [
       'elegant', 'neo', 'mdn-like', 'material', 'monokai', 'twilight'
     ]
+    this.keymapModes = {
+      default: 'Default',
+      vim: 'Vim',
+      emacs: 'Emacs',
+      sublime: 'Sublime Text',
+    }
 
     this.onChangeTheme = this.onChangeTheme.bind(this);
+    this.onChangeKeymapMode = this.onChangeKeymapMode.bind(this);
     this.onClickStyleActiveLine = this.onClickStyleActiveLine.bind(this);
     this.onClickRenderMathJaxInRealtime = this.onClickRenderMathJaxInRealtime.bind(this);
     this.onToggleConfigurationDropdown = this.onToggleConfigurationDropdown.bind(this);
@@ -43,6 +50,7 @@ export default class OptionsSelector extends React.Component {
 
   init() {
     this.themeSelectorInputEl.value = this.state.editorOptions.theme;
+    this.keymapModeSelectorInputEl.value = this.state.editorOptions.keymapMode;
   }
 
   onChangeTheme() {
@@ -54,6 +62,15 @@ export default class OptionsSelector extends React.Component {
     this.dispatchOnChange();
   }
 
+  onChangeKeymapMode() {
+    const newValue = this.keymapModeSelectorInputEl.value;
+    const newOpts = Object.assign(this.state.editorOptions, {keymapMode: newValue});
+    this.setState({editorOptions: newOpts});
+
+    // dispatch event
+    this.dispatchOnChange();
+  }
+
   onClickStyleActiveLine(event) {
     // keep dropdown opened
     this._cddForceOpen = true;
@@ -121,6 +138,29 @@ export default class OptionsSelector extends React.Component {
     )
   }
 
+  renderKeymapModeSelector() {
+    const optionElems = [];
+    for (let mode in this.keymapModes) {
+      const label = this.keymapModes[mode];
+      optionElems.push(<option key={mode} value={mode}>{label}</option>);
+    }
+
+    const bsClassName = 'form-control-dummy'; // set form-control* to shrink width
+
+    return (
+      <FormGroup controlId="formControlsSelect">
+        <ControlLabel>Mode:</ControlLabel>
+        <FormControl componentClass="select" placeholder="select" bsClass={bsClassName} className="btn-group-sm selectpicker"
+            onChange={this.onChangeKeymapMode}
+            inputRef={ el => this.keymapModeSelectorInputEl=el }>
+
+          {optionElems}
+
+        </FormControl>
+      </FormGroup>
+    )
+  }
+
   renderConfigurationDropdown() {
     return (
       <FormGroup controlId="formControlsSelect">
@@ -188,6 +228,7 @@ export default class OptionsSelector extends React.Component {
   render() {
     return <span>
       <span className="m-l-5">{this.renderThemeSelector()}</span>
+      <span className="m-l-5">{this.renderKeymapModeSelector()}</span>
       <span className="m-l-5">{this.renderConfigurationDropdown()}</span>
     </span>
   }
@@ -196,6 +237,7 @@ export default class OptionsSelector extends React.Component {
 export class EditorOptions {
   constructor(props) {
     this.theme = 'elegant';
+    this.keymapMode = 'default';
     this.styleActiveLine = false;
 
     Object.assign(this, props);

+ 8 - 0
yarn.lock

@@ -4039,6 +4039,10 @@ linkify-it@^2.0.0:
   dependencies:
     uc.micro "^1.0.1"
 
+load-css-file@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/load-css-file/-/load-css-file-1.0.0.tgz#dac097ead6470f4c3f23d4bc5b9ff2c3decb212f"
+
 load-json-file@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
@@ -6693,6 +6697,10 @@ signal-exit@^3.0.0, signal-exit@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
 
+simple-load-script@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/simple-load-script/-/simple-load-script-1.0.2.tgz#d92951fe7b601ad90af8c9429bd4b2ee127ab8a3"
+
 sinon-chai@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-3.0.0.tgz#d5cbd70fa71031edd96b528e0eed4038fcc99f29"