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

Merge branch 'master' into feat/post-comment-notification-takizawa

yusuketk 7 лет назад
Родитель
Сommit
b57ae47907

+ 10 - 1
CHANGES.md

@@ -1,12 +1,16 @@
 CHANGES
 ========
 
-## 3.1.8-RC
+## 3.1.10-RC
+
+
+## 3.1.9
 
 * Feature: Login with Google Account
 * Feature: Login with GitHub Account
 * Feature: Attach files in Comment
 * Improvement: Write comment with CodeMirror Editor
+* Improvement: Post comment with Ctrl-Enter
 * Improvement: Place the commented page at the beginning of the list
 * Improvement: Resolve errors on IE11 (Experimental)
 * Support: Migrate to webpack 4 
@@ -16,6 +20,10 @@ CHANGES
     * react-codemirror2
     * webpack
 
+
+## 3.1.8 (Missing number)
+
+
 ## 3.1.7
 
 * Fix: Update hidden input 'pageForm[grant]' when save with Ctrl-S
@@ -58,6 +66,7 @@ CHANGES
 
 ## 3.1.4 (Missing number)
 
+
 ## 3.1.3 (Missing number)
 
 

+ 6 - 1
README.md

@@ -158,13 +158,18 @@ Environment Variables
     * NODE_ENV: `production` OR `development`.
     * PORT: Server port. default: `3000`
     * ELASTICSEARCH_URI: URI to connect to Elasticearch.
-    * REDIS_URI: URI to connect to Redis (to session store).
+    * REDIS_URI: URI to connect to Redis (use it as a session store instead of MongoDB).
     * PLANTUML_URI: URI to connect to [PlantUML](http://plantuml.com/) server.
     * BLOCKDIAG_URI: URI to connect to [blockdiag](http://http://blockdiag.com/) server.
     * PASSWORD_SEED: A password seed used by password hash generator.
     * SECRET_TOKEN: A secret key for verifying the integrity of signed cookies.
     * SESSION_NAME: The name of the session ID cookie to set in the response by Express. default: `connect.sid`
     * FILE_UPLOAD: `aws` (default), `local`, `none`
+* **Option (Overwritable in admin page)**
+    * OAUTH_GOOGLE_CLIENT_ID: Google API client id for OAuth login
+    * OAUTH_GOOGLE_CLIENT_SECRET: Google API client secret for OAuth login
+    * OAUTH_GITHUB_CLIENT_ID: GitHub API client id for OAuth login
+    * OAUTH_GITHUB_CLIENT_SECRET: GitHub API client secret for OAuth login
 
 
 Documentation

+ 12 - 3
THIRD-PARTY-NOTICES.md

@@ -12,9 +12,18 @@ For any licenses that require disclosure of source, sources are available at
 https://github.com/weseek/growi.
 
 
-1. crowi/crowi (https://github.com/crowi/crowi)
-2. Microsoft/vscode (https://github.com/Microsoft/vscode)
-3. stephenhutchings/typicons.font (https://github.com/stephenhutchings/typicons.font)
+1. Apache License, Version 2.0 Derivative Works
+2. crowi/crowi (https://github.com/crowi/crowi)
+3. Microsoft/vscode (https://github.com/Microsoft/vscode)
+4. stephenhutchings/typicons.font (https://github.com/stephenhutchings/typicons.font)
+
+
+License Notice for Apache License, Version 2.0 Derivative Works
+--------------------------------------------------------
+
+https://www.apache.org/licenses/LICENSE-2.0
+
+This software includes works that is distributed in the Apache License 2.0
 
 
 License Notice for Crowi

+ 18 - 14
lib/locales/en-US/translation.json

@@ -206,20 +206,23 @@
   },
 
   "modal_shortcuts": {
-      "global": {
-          "title": "Global shortcuts",
-          "Open/Close shortcut help": "Open/Close shortcut help",
-          "Edit Page": "Edit Page",
-          "Create Page": "Create Page"
-      },
-      "editor": {
-          "title": "Editor shortcuts",
-          "Indent": "Indent",
-          "Outdent": "Outdent",
-          "Save Page": "Save Page",
-          "Delete Line": "Delete Line"
-
-              }
+    "global": {
+      "title": "Global shortcuts",
+      "Open/Close shortcut help": "Open/Close shortcut help",
+      "Edit Page": "Edit Page",
+      "Create Page": "Create Page"
+    },
+    "editor": {
+      "title": "Editor shortcuts",
+      "Indent": "Indent",
+      "Outdent": "Outdent",
+      "Save Page": "Save Page",
+      "Delete Line": "Delete Line"
+    },
+    "commentform": {
+      "title": "Comment Form shortcuts",
+      "Post": "Post"
+    }
   },
 
   "template": {
@@ -337,6 +340,7 @@
     "optional": "Optional",
     "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>%s</code> match",
     "Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>%s</code>.",
+    "Use env var if empty": "Use env var <code>%s</code> if empty",
     "ldap": {
       "server_url_detail": "The LDAP URL of the directory service in the format <code>ldap://host:port/DN</code> or <code>ldaps://host:port/DN</code>.",
       "bind_mode": "Binding Mode",

+ 4 - 0
lib/locales/ja/translation.json

@@ -234,6 +234,9 @@
         "Outdent": "左インデント",
         "Save Page": "保存",
         "Delete Line": "行削除"
+    },
+    "commentform": {
+      "Post": "投稿"
     }
   },
 
@@ -354,6 +357,7 @@
     "optional": "オプション",
     "Treat username matching as identical": "新規ログイン時、<code>%s</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
     "Treat username matching as identical_warn": "警告: <code>%s</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
+    "Use env var if empty": "空の場合、環境変数 <code>%s</code> を利用します",
     "ldap": {
       "server_url_detail": "LDAP URLを <code>ldap://host:port/DN</code> または <code>ldaps://host:port/DN</code> の形式で入力してください。",
       "bind_mode": "Bind モード",

+ 2 - 2
lib/routes/admin.js

@@ -956,13 +956,13 @@ module.exports = function(crowi, app) {
     // reset strategy
     await crowi.passportService.resetGitHubStrategy();
     // setup strategy
-    if (Config.isEnabledPassportGoogle(config)) {
+    if (Config.isEnabledPassportGitHub(config)) {
       try {
         await crowi.passportService.setupGitHubStrategy(true);
       }
       catch (err) {
         // reset
-        await crowi.passportService.resetGoogleStrategy();
+        await crowi.passportService.resetGitHubStrategy();
         return res.json({status: false, message: err.message});
       }
     }

+ 2 - 2
lib/routes/index.js

@@ -70,8 +70,8 @@ module.exports = function(crowi, app) {
   // OAuth
   app.post('/_api/admin/security/passport-google' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportGoogle, admin.api.securityPassportGoogleSetting);
   app.post('/_api/admin/security/passport-github' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportGitHub, admin.api.securityPassportGitHubSetting);
-  app.get('/passport/google'                      , loginPassport.loginPassportGoogle);
-  app.get('/passport/github'                      , loginPassport.loginPassportGitHub);
+  app.get('/passport/google'                      , loginPassport.loginWithGoogle);
+  app.get('/passport/github'                      , loginPassport.loginWithGitHub);
   app.get('/passport/google/callback'             , loginPassport.loginPassportGoogleCallback);
   app.get('/passport/github/callback'             , loginPassport.loginPassportGitHubCallback);
 

+ 18 - 16
lib/routes/login-passport.js

@@ -1,7 +1,7 @@
 module.exports = function(crowi, app) {
   'use strict';
 
-  var debug = require('debug')('growi:routes:login-passport')
+  const debug = require('debug')('growi:routes:login-passport')
     , logger = require('@alias/logger')('growi:routes:login-passport')
     , passport = require('passport')
     , config = crowi.getConfig()
@@ -24,7 +24,7 @@ module.exports = function(crowi, app) {
       }
     });
 
-    var jumpTo = req.session.jumpTo;
+    const jumpTo = req.session.jumpTo;
     if (jumpTo) {
       req.session.jumpTo = null;
       return res.redirect(jumpTo);
@@ -101,7 +101,7 @@ module.exports = function(crowi, app) {
       'id': ldapAccountId,
       'username': usernameToBeRegistered,
       'name': nameToBeRegistered
-    }
+    };
 
     const externalAccount = await getOrCreateUser(req, res, next, userInfo, providerId);
     if (!externalAccount) {
@@ -112,7 +112,7 @@ module.exports = function(crowi, app) {
 
     // login
     await req.logIn(user, err => {
-      if (err) { return next(err) };
+      if (err) { return next(err) }
       return loginSuccess(req, res, user);
     });
   };
@@ -205,10 +205,11 @@ module.exports = function(crowi, app) {
     })(req, res, next);
   };
 
-  const loginPassportGoogle = function(req, res) {
+  const loginWithGoogle = function(req, res, next) {
     if (!passportService.isGoogleStrategySetup) {
       debug('GoogleStrategy has not been set up');
-      return;
+      req.flash('warningMessage', 'GoogleStrategy has not been set up');
+      return next();
     }
 
     passport.authenticate('google', {
@@ -224,7 +225,7 @@ module.exports = function(crowi, app) {
       'id': response.id,
       'username': response.displayName,
       'name': `${response.name.givenName} ${response.name.familyName}`
-    }
+    };
     const externalAccount = await getOrCreateUser(req, res, next, userInfo, providerId);
     if (!externalAccount) {
       return loginFailure(req, res, next);
@@ -234,15 +235,16 @@ module.exports = function(crowi, app) {
 
     // login
     req.logIn(user, err => {
-      if (err) { return next(err) };
+      if (err) { return next(err) }
       return loginSuccess(req, res, user);
     });
   };
 
-  const loginPassportGitHub = function(req, res) {
+  const loginWithGitHub = function(req, res, next) {
     if (!passportService.isGitHubStrategySetup) {
       debug('GitHubStrategy has not been set up');
-      return;
+      req.flash('warningMessage', 'GitHubStrategy has not been set up');
+      return next();
     }
 
     passport.authenticate('github')(req, res);
@@ -256,7 +258,7 @@ module.exports = function(crowi, app) {
       'id': response.id,
       'username': response.username,
       'name': response.displayName
-    }
+    };
 
     const externalAccount = await getOrCreateUser(req, res, next, userInfo, providerId);
     if (!externalAccount) {
@@ -267,7 +269,7 @@ module.exports = function(crowi, app) {
 
     // login
     req.logIn(user, err => {
-      if (err) { return next(err) };
+      if (err) { return next(err) }
       return loginSuccess(req, res, user);
     });
   };
@@ -290,7 +292,7 @@ module.exports = function(crowi, app) {
           return next();
         }
 
-        resolve(response)
+        resolve(response);
       })(req, res, next);
     });
   };
@@ -321,15 +323,15 @@ module.exports = function(crowi, app) {
         }
       }
     }
-  }
+  };
 
   return {
     loginFailure,
     loginWithLdap,
     testLdapCredentials,
     loginWithLocal,
-    loginPassportGoogle,
-    loginPassportGitHub,
+    loginWithGoogle,
+    loginWithGitHub,
     loginPassportGoogleCallback,
     loginPassportGitHubCallback,
   };

+ 4 - 4
lib/service/passport.js

@@ -265,8 +265,8 @@ class PassportService {
 
     debug('GoogleStrategy: setting up..');
     passport.use(new GoogleStrategy({
-      clientId: config.crowi['security:passport-google:clientId'],
-      clientSecret: config.crowi['security:passport-google:clientSecret'],
+      clientId: config.crowi['security:passport-google:clientId'] || process.env.OAUTH_GOOGLE_CLIENT_SECRET,
+      clientSecret: config.crowi['security:passport-google:clientSecret'] || process.env.OAUTH_GOOGLE_CLIENT_SECRET,
       callbackURL: 'http://localhost:3000/passport/google/callback',  //change this
       skipUserProfile: false,
     }, function(accessToken, refreshToken, profile, done) {
@@ -311,8 +311,8 @@ class PassportService {
 
     debug('GitHubStrategy: setting up..');
     passport.use(new GitHubStrategy({
-      clientID: config.crowi['security:passport-github:clientId'],
-      clientSecret: config.crowi['security:passport-github:clientSecret'],
+      clientID: config.crowi['security:passport-github:clientId'] || process.env.OAUTH_GITHUB_CLIENT_ID,
+      clientSecret: config.crowi['security:passport-github:clientSecret'] || process.env.OAUTH_GITHUB_CLIENT_SECRET,
       callbackURL: 'http://localhost:3000/passport/github/callback',  //change this
       skipUserProfile: false,
     }, function(accessToken, refreshToken, profile, done) {

+ 1 - 1
lib/views/admin/app.html

@@ -92,7 +92,7 @@
         <div class="form-group">
           <label for="settingForm[mail.from]" class="col-xs-3 control-label">{{ t('app_setting.From e-mail address') }}</label>
           <div class="col-xs-6">
-            <input class="form-control" type="text" name="settingForm[mail:from]" placeholder="例: mail@crowi.wiki" value="{{ settingForm['mail:from'] }}">
+            <input class="form-control" type="text" name="settingForm[mail:from]" placeholder="例: mail@growi.org" value="{{ settingForm['mail:from'] }}">
           </div>
         </div>
 

+ 21 - 12
lib/views/admin/notification.html

@@ -126,7 +126,7 @@
                     This is the way that compatible with Crowi,<br>
                     but not recommended in GROWI because it is <strong>too complex</strong>.
                     <br><br>
-                    Please use <a href="#slack-incoming-webhooks" data-toggle="tab" onclick="activateTab('slack-incoming-webhooks')">Slack incomming webhooks Configuration</a> instead.
+                    Please use <a href="#slack-incoming-webhooks" data-toggle="tab" onclick="activateSlackIwh()">Slack incomming webhooks Configuration</a> instead.
                   </p>
 
                   <div class="form-group">
@@ -260,30 +260,39 @@
   </div>
 
   <script>
+    function activateTab(tab){
+      $('.nav-tabs a[href="#' + tab + '"]').tab('show');
+    };
+
+    function activateSlackIwh() {
+      $("#selectSlackOption").selectpicker('val', '1');
+      $("#slack-app").removeClass('active');
+      $("#slack-incoming-webhooks").addClass('active');
+    }
+
+    function activateSlackApp() {
+      $("#selectSlackOption").selectpicker('val', '2');
+      $("#slack-incoming-webhooks").removeClass('active');
+      $("#slack-app").addClass('active');
+    }
+
     window.addEventListener('load', function(e) {
       // hash on page
       if (location.hash) {
-        if (location.hash == '#slack-app') {
-          activateTab('slack-app');
+        if (location.hash == '#global-notification') {
+          activateTab('global-notification');
         }
       }
     });
 
     $("#selectSlackOption").on('change', function() {
       if (this.value === "1") {
-        $("#slack-app").removeClass('active');
-        $("#slack-incoming-webhooks").addClass('active');
+        activateSlackIwh();
       }
       else if (this.value === "2") {
-        $("#slack-incoming-webhooks").removeClass('active');
-        $("#slack-app").addClass('active');
+        activateSlackApp();
       }
     });
-
-
-    function activateTab(tab){
-      $('.nav-tabs a[href="#' + tab + '"]').tab('show');
-    };
   </script>
 </div>
 {% endblock content_main %}

+ 1 - 1
lib/views/admin/security.html

@@ -84,7 +84,7 @@
           <div class="form-group">
             <label for="settingForm[security:registrationWhiteList]" class="col-xs-3 control-label">{{ t('The whitelist of registration permission E-mail address') }}</label>
             <div class="col-xs-8">
-              <textarea class="form-control" type="textarea" name="settingForm[security:registrationWhiteList]" placeholder="{{ t('security_setting.example') }}: @crowi.wiki">{{ settingForm['security:registrationWhiteList']|join('&#13')|raw }}</textarea>
+              <textarea class="form-control" type="textarea" name="settingForm[security:registrationWhiteList]" placeholder="{{ t('security_setting.example') }}: @growi.org">{{ settingForm['security:registrationWhiteList']|join('&#13')|raw }}</textarea>
               <p class="help-block">{{ t("security_setting.restrict_emails") }}{{ t("security_setting.for_instance") }}<code>@growi.org</code>{{ t("security_setting.only_those") }}<br>
               {{ t("security_setting.insert_single") }}</p>
             </div>

+ 1 - 1
lib/views/admin/users.html

@@ -45,7 +45,7 @@
         <div id="inviteUserForm" class="collapse">
           <div class="form-group">
             <label for="inviteForm[emailList]">メールアドレス (複数行入力で複数人招待可能)</label>
-            <textarea class="form-control" name="inviteForm[emailList]" placeholder="例: user@crowi.wiki"></textarea>
+            <textarea class="form-control" name="inviteForm[emailList]" placeholder="例: user@growi.org"></textarea>
           </div>
           <div class="checkbox checkbox-info">
             <input type="checkbox" id="inviteWithEmail" name="inviteForm[sendEmail]" checked>

+ 10 - 0
lib/views/admin/widget/passport/github.html

@@ -26,6 +26,11 @@
       <label for="settingForm[security:passport-github:clientId]" class="col-xs-3 control-label">{{ t("security_setting.clientID") }}</label>
       <div class="col-xs-6">
         <input class="form-control" type="text" name="settingForm[security:passport-github:clientId]" value="{{ settingForm['security:passport-github:clientId'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.Use env var if empty", "OAUTH_GITHUB_CLIENT_SECRET") }}
+          </small>
+        </p>
       </div>
     </div>
 
@@ -33,6 +38,11 @@
       <label for="settingForm[security:passport-github:clientSecret]" class="col-xs-3 control-label">{{ t("security_setting.client_secret") }}</label>
       <div class="col-xs-6">
         <input class="form-control" type="text" name="settingForm[security:passport-github:clientSecret]" value="{{ settingForm['security:passport-github:clientSecret'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.Use env var if empty", "OAUTH_GITHUB_CLIENT_SECRET") }}
+          </small>
+        </p>
       </div>
     </div>
     <div class="form-group">

+ 10 - 0
lib/views/admin/widget/passport/google-oauth.html

@@ -26,6 +26,11 @@
       <label for="settingForm[security:passport-google:clientId]" class="col-xs-3 control-label">{{ t("security_setting.clientID") }}</label>
       <div class="col-xs-6">
         <input class="form-control" type="text" name="settingForm[security:passport-google:clientId]" value="{{ settingForm['security:passport-google:clientId'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.Use env var if empty", "OAUTH_GOOGLE_CLIENT_ID") }}
+          </small>
+        </p>
       </div>
     </div>
 
@@ -33,6 +38,11 @@
       <label for="settingForm[security:passport-google:clientSecret]" class="col-xs-3 control-label">{{ t("security_setting.client_secret") }}</label>
       <div class="col-xs-6">
         <input class="form-control" type="text" name="settingForm[security:passport-google:clientSecret]" value="{{ settingForm['security:passport-google:clientSecret'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.Use env var if empty", "OAUTH_GOOGLE_CLIENT_SECRET") }}
+          </small>
+        </p>
       </div>
     </div>
     <div class="form-group">

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

@@ -52,7 +52,29 @@
             </table>
           </div><!-- /.col-sm-6 -->
 
-        </div>
+        </div><!-- /.row -->
+
+        <div class="row">
+          <div class="col-sm-6">
+            <h3><strong></strong></h3>
+          </div><!-- /.col-sm-6 -->
+
+          <div class="col-sm-6">
+            <h3><strong>{{ t('modal_shortcuts.commentform.title') }}</strong></h3>
+
+            <table class="table">
+              <tr>
+                <th>{{ t('modal_shortcuts.commentform.Post') }}:</th>
+                <td><span class="key cmd-key"></span> + <span class="key key-longer">{% include '../widget/icon-keyboard-return-enter.html' %}</span></td>
+              </tr>
+              <tr>
+                <th>{{ t('modal_shortcuts.editor.Delete Line') }}:</th>
+                <td><span class="key cmd-key"></span> + <span class="key">D</span></td>
+              </tr>
+            </table>
+          </div><!-- /.col-sm-6 -->
+
+        </div><!-- /.row -->
 
       </div>
 

+ 7 - 0
lib/views/widget/icon-keyboard-return-enter.html

@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="20px" viewBox="0 0 34 21">
+  <g id="ba5f4106-f870-416b-bb0c-2580c9a76268">
+    <g id="1def15e1-5198-4ca2-9457-3b509e83053f">
+      <polygon points="31 0 31 9 5 9 11.8 1.8 10 0 0 10.5 10 21 11.8 19.2 5 12 34 12 34 0 31 0" />
+    </g>
+  </g>
+</svg>

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.1.8-RC2",
+  "version": "3.1.10-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 5 - 1
resource/js/components/PageComment/CommentForm.js

@@ -72,7 +72,10 @@ export default class CommentForm extends React.Component {
    * Load data of comments and rerender <PageComments />
    */
   postComment(event) {
-    event.preventDefault();
+    if (event != null) {
+      event.preventDefault();
+    }
+
     this.props.crowi.apiPost('/comments.add', {
       commentForm: {
         comment: this.state.comment,
@@ -222,6 +225,7 @@ export default class CommentForm extends React.Component {
                         emojiStrategy={emojiStrategy}
                         onChange={this.updateState}
                         onUpload={this.onUpload}
+                        onCtrlEnter={this.postComment}
                       />
                     </Tab>
                     { this.state.isMarkdown == true &&

+ 2 - 0
resource/js/components/PageEditor/AbstractEditor.js

@@ -109,6 +109,7 @@ export default class AbstractEditor extends React.Component {
       this.props.onPasteFiles(event);
     }
   }
+
 }
 
 AbstractEditor.propTypes = {
@@ -121,6 +122,7 @@ AbstractEditor.propTypes = {
   onSave: PropTypes.func,
   onPasteFiles: PropTypes.func,
   onDragEnter: PropTypes.func,
+  onCtrlEnter: PropTypes.func,
 };
 AbstractEditor.defaultProps = {
   isGfmMode: true,

+ 24 - 5
resource/js/components/PageEditor/CodeMirrorEditor.js

@@ -8,6 +8,15 @@ const loadScript = require('simple-load-script');
 const loadCssSync = require('load-css-file');
 
 import * as codemirror from 'codemirror';
+// set save handler
+codemirror.commands.save = (instance) => {
+  if (instance.codeMirrorEditor != null) {
+    instance.codeMirrorEditor.dispatchSave();
+  }
+};
+// set CodeMirror instance as 'CodeMirror' so that CDN addons can reference
+window.CodeMirror = require('codemirror');
+
 
 import { UnControlled as ReactCodeMirror } from 'react-codemirror2';
 require('codemirror/addon/edit/matchbrackets');
@@ -62,6 +71,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.loadKeymapMode = this.loadKeymapMode.bind(this);
     this.setKeymapMode = this.setKeymapMode.bind(this);
     this.handleEnterKey = this.handleEnterKey.bind(this);
+    this.handleCtrlEnterKey = this.handleCtrlEnterKey.bind(this);
 
     this.scrollCursorIntoViewHandler = this.scrollCursorIntoViewHandler.bind(this);
     this.pasteHandler = this.pasteHandler.bind(this);
@@ -91,13 +101,11 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
 
   componentDidMount() {
+    // ensure to be able to resolve 'this' to use 'codemirror.commands.save'
+    this.getCodeMirror().codeMirrorEditor = this;
+
     // 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');
   }
 
   componentWillReceiveProps(nextProps) {
@@ -362,6 +370,15 @@ export default class CodeMirrorEditor extends AbstractEditor {
       });
   }
 
+  /**
+   * handle Ctrl+ENTER key
+   */
+  handleCtrlEnterKey() {
+    if (this.props.onCtrlEnter != null) {
+      this.props.onCtrlEnter();
+    }
+  }
+
   scrollCursorIntoViewHandler(editor, event) {
     if (this.props.onScrollCursorIntoView != null) {
       const line = editor.getCursor().line;
@@ -461,6 +478,8 @@ export default class CodeMirrorEditor extends AbstractEditor {
           // continuelist, indentlist
           extraKeys: {
             'Enter': this.handleEnterKey,
+            'Ctrl-Enter': this.handleCtrlEnterKey,
+            'Cmd-Enter': this.handleCtrlEnterKey,
             'Tab': 'indentMore',
             'Shift-Tab': 'indentLess',
             'Ctrl-Q': (cm) => { cm.foldCode(cm.getCursor()) },

+ 1 - 1
resource/js/legacy/crowi.js

@@ -987,7 +987,7 @@ window.addEventListener('keydown', (event) => {
 
   // ignore when target dom is input
   const inputPattern = /^input|textinput|textarea$/i;
-  if (target.tagName.match(inputPattern) || target.isContentEditable) {
+  if (inputPattern.test(target.tagName) || target.isContentEditable) {
     return;
   }
 

+ 4 - 0
resource/styles/scss/_shortcuts.scss

@@ -38,6 +38,10 @@
     text-transform: uppercase;
     text-align: center;
     color: #666;
+    /* SVG Properties*/
+    polygon {
+      fill: #666;
+    }
 
     &.key-longer {
       width: 64px;