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

Merge branch 'master' into imprv/enable-new-page-name-input-box-to-searchable

Ryu Sato 8 лет назад
Родитель
Сommit
c5e2cafa8e
62 измененных файлов с 2119 добавлено и 680 удалено
  1. 29 1
      CHANGES.md
  2. 3 1
      config/webpack.common.js
  3. 7 10
      lib/crowi/index.js
  4. 1 1
      lib/form/admin/app.js
  5. 13 12
      lib/locales/en-US/translation.json
  6. 14 15
      lib/locales/ja/translation.json
  7. 4 1
      lib/models/config.js
  8. 1 0
      lib/routes/page.js
  9. 47 46
      lib/views/_form.html
  10. 4 49
      lib/views/admin/customize.html
  11. 1 1
      lib/views/admin/security.html
  12. 1 8
      lib/views/crowi-plus/base/not_found_nosidebar.html
  13. 1 8
      lib/views/crowi-plus/base/page_list_nosidebar.html
  14. 1 8
      lib/views/crowi-plus/base/page_nosidebar.html
  15. 1 8
      lib/views/crowi-plus/base/user_page_nosidebar.html
  16. 3 0
      lib/views/crowi-plus/page_list.html
  17. 24 0
      lib/views/crowi-plus/widget/system-version.html
  18. 1 1
      lib/views/layout/2column.html
  19. 6 4
      lib/views/layout/layout.html
  20. 17 1
      lib/views/layout/single.html
  21. 0 113
      lib/views/modal/help.html
  22. 79 0
      lib/views/modal/shortcuts.html
  23. 0 5
      lib/views/not_found.html
  24. 1 2
      lib/views/page.html
  25. 0 1
      lib/views/page_list.html
  26. 15 10
      package.json
  27. 32 29
      resource/css/_admin.scss
  28. 180 66
      resource/css/_form.scss
  29. 11 11
      resource/css/_layout_crowi-plus.scss
  30. 2 2
      resource/css/_page.scss
  31. 66 0
      resource/css/_shortcuts.scss
  32. 2 4
      resource/css/_wiki.scss
  33. 13 1
      resource/css/crowi.scss
  34. 71 4
      resource/js/app.js
  35. 61 0
      resource/js/components/Admin/CustomCssEditor.js
  36. 61 0
      resource/js/components/Admin/CustomScriptEditor.js
  37. 3 1
      resource/js/components/HeaderSearchBox/SearchForm.js
  38. 2 2
      resource/js/components/Page/RevisionUrl.js
  39. 6 5
      resource/js/components/PageAttachment/DeleteAttachmentModal.js
  40. 3 1
      resource/js/components/PageComment/DeleteCommentModal.js
  41. 1 1
      resource/js/components/PageComments.js
  42. 315 0
      resource/js/components/PageEditor.js
  43. 357 0
      resource/js/components/PageEditor/Editor.js
  44. 147 0
      resource/js/components/PageEditor/EmojiAutoCompleteHelper.js
  45. 140 0
      resource/js/components/PageEditor/PasteHelper.js
  46. 27 0
      resource/js/components/PageEditor/Preview.js
  47. 57 0
      resource/js/components/PageEditor/ThemeSelector.js
  48. 1 5
      resource/js/components/PageHistory.js
  49. 1 0
      resource/js/components/PageHistory/PageRevisionList.js
  50. 6 4
      resource/js/components/PageHistory/Revision.js
  51. 3 1
      resource/js/components/SearchPage/DeletePageListModal.js
  52. 9 55
      resource/js/legacy/crowi-form.js
  53. 106 35
      resource/js/legacy/crowi.js
  54. 27 23
      resource/js/util/Crowi.js
  55. 3 2
      resource/js/util/CrowiRenderer.js
  56. 2 1
      resource/js/util/LangProcessor/PlantUML.js
  57. 3 2
      resource/js/util/LangProcessor/Tsv2Table.js
  58. 0 2
      resource/js/util/PostProcessor/Emoji.js
  59. 5 5
      test/models/page.test.js
  60. 8 0
      test/models/user.test.js
  61. 1 1
      test/utils.js
  62. 113 111
      yarn.lock

+ 29 - 1
CHANGES.md

@@ -1,13 +1,41 @@
 CHANGES
 ========
 
-## 2.3.4-RC
+## 2.3.8-RC
 
+* Improvement: Prevent keyboard shortcuts when modal is opened
+* Improvement: PageHistory UI
+
+## 2.3.7
+
+* Fix: Open popups when `Ctrl+C` pressed
+    * Introduced by 2.3.5
+
+## 2.3.6
+
+* Feature: Theme Selector for Editor
+* Improvement: Remove unportalize button from crowi-plus layout
+* Fix: CSS for admin pages
+* Support: Shrink the size of libraries to include
+
+## 2.3.5
+
+* Feature: Enhanced Editor by CodeMirror
+* Feature: Emoji AutoComplete
+* Feature: Add keyboard shortcuts
+* Improvement: Attaching file with Dropzone.js
+* Improvement: Show shortcuts help with `Ctrl-/`
+* Fix: DOMs that has `.alert-info` class don't be displayed
 * Support: Switch and upgrade libs
     * 8fold-marked -> marked
     * react-bootstrap
+    * googleapis
+    * mongoose
+    * mongoose-unique-validator
     * etc..
 
+## 2.3.4 (Missing number)
+
 ## 2.3.3
 
 * Fix: The XSS Library escapes inline code blocks

+ 3 - 1
config/webpack.common.js

@@ -32,7 +32,8 @@ module.exports = function (options) {
     externals: {
       // require("jquery") is external and available
       //  on the global var jQuery
-      "jquery": "jQuery"
+      "jquery": "jQuery",
+      "emojione": "emojione",
     },
     resolve: {
       extensions: ['.js', '.json'],
@@ -100,6 +101,7 @@ module.exports = function (options) {
         jQuery: "jquery",
         $: "jquery",
         hljs: "reveal.js/plugin/highlight/highlight",
+        emojione: "emojione",
       }),
 
     ]

+ 7 - 10
lib/crowi/index.js

@@ -155,16 +155,13 @@ Crowi.prototype.setupDatabase = function() {
     ((process.env.NODE_ENV === 'test') ? 'mongodb://localhost/crowi_test' : 'mongodb://localhost/crowi')
     ;
 
-  return new Promise(function(resolve, reject) {
-    mongoose.connect(mongoUri, { useMongoClient: true }, function(e) {
-      if (e) {
-        debug('DB Connect Error: ', e);
-        debug('DB Connect Error: ', mongoUri);
-        return reject(new Error('Cann\'t connect to Database Server.'));
-      }
-      return resolve();
-    });
-  });
+  return mongoose.connect(mongoUri).then(
+    () => {},
+    err => {
+      debug('DB Connect Error: ', err);
+      debug('DB Connect Error: ', mongoUri);
+    }
+  );
 };
 
 Crowi.prototype.setupSessionConfig = function() {

+ 1 - 1
lib/form/admin/app.js

@@ -6,6 +6,6 @@ var form = require('express-form')
 module.exports = form(
   field('settingForm[app:title]').required(),
   field('settingForm[app:confidential]'),
-  field('settingForm[app:fileUpload]').isInt()
+  field('settingForm[app:fileUpload]').trim().toBooleanStrict()
 );
 

+ 13 - 12
lib/locales/en-US/translation.json

@@ -12,6 +12,7 @@
   "Create": "Create",
   "Admin": "Admin",
   "New": "New",
+  "Shortcuts": "Shortcuts",
 
   "Update": "Update",
   "Update Page": "Update Page",
@@ -188,19 +189,19 @@
     }
   },
 
-  "modal_help": {
-      "basic": {
-          "title": "Basics",
-          "body1": "There are 2 types of pages: List pages (showing lists of links to other pages) and normal pages.",
-          "body2": "Pages that end with a slash / are List pages for anything following the slash.",
-          "body3": "You can view older versions of a page from the History tab. Any changes made will be stored here."
+  "modal_shortcuts": {
+      "global": {
+          "title": "Global shortcuts",
+          "Open/Close shortcut help": "Open/Close shortcut help",
+          "Edit Page": "Edit Page",
+          "Create Page": "Create Page"
       },
-      "tips": {
-          "title": "Quick Tips on Editing",
-          "body1": "Use sections and subsections to make it easier for your friends to read your pages."
-      },
-      "markdown": {
-          "title": "Markdown Rules"
+      "editor": {
+          "title": "Editor shortcuts",
+          "Indent": "Indent",
+          "Outdent": "Outdent",
+          "Save Page": "Save Page",
+          "Delete Line": "Delete Line"
       }
   }
 }

+ 14 - 15
lib/locales/ja/translation.json

@@ -12,6 +12,7 @@
   "Create": "作成",
   "Admin": "管理",
   "New": "作成",
+  "Shortcuts": "ショートカット",
 
   "Update": "更新",
   "Update Page": "ページを更新",
@@ -187,19 +188,17 @@
     }
   },
 
-  "modal_help": {
-      "basic": {
-          "title": "基本的な機能",
-          "body1": "表示される画面には、「一覧ページ」と「ページ」の2種類があります",
-          "body2": "スラッシュ <code>/</code> で終わるページは、その階層の一覧ページとなります。",
-          "body3": "ページでの変更はすべて記録されています。History からそのページの過去の状態を見ることができます。"
-      },
-      "tips": {
-          "title": "編集のコツ",
-          "body1": "文章の <strong>構造</strong> を意識しましょう。本を書くように、内容と文脈を整理してセクション・サブセクション...と構造的に書くと、わかりやすく他人に伝わりやすいページがになります。"
-      },
-      "markdown": {
-          "title": "記法"
-      }
-  }
+  "modal_shortcuts": {
+    "global": {
+        "Open/Close shortcut help": "ショートカットヘルプの表示/非表示",
+        "Edit Page": "ページ編集",
+        "Create Page": "ページ作成"
+    },
+    "editor": {
+        "Indent": "インデント",
+        "Outdent": "左インデント",
+        "Save Page": "保存",
+        "Delete Line": "行削除"
+    }
+}
 }

+ 4 - 1
lib/models/config.js

@@ -28,6 +28,7 @@ module.exports = function(crowi) {
 
     // overwrite
     config['app:title'] = 'crowi-plus';
+    config['app:fileUpload'] = true;
     config['security:isEnabledPassport'] = true;
     config['customize:behavior'] = 'crowi-plus';
     config['customize:layout'] = 'crowi-plus';
@@ -382,7 +383,8 @@ module.exports = function(crowi) {
       return false;
     }
 
-    return config.crowi['app:fileUpload'] || false;
+    // convert to boolean
+    return !!config.crowi['app:fileUpload'];
   };
 
   configSchema.statics.hasSlackConfig = function(config)
@@ -451,6 +453,7 @@ module.exports = function(crowi) {
       },
       behaviorType: Config.behaviorType(config),
       layoutType: Config.layoutType(config),
+      isEnabledLineBreaks: Config.isEnabledLinebreaks(config),
       isSavedStatesOfTabChanges: Config.isSavedStatesOfTabChanges(config),
       env: {
         PLANTUML_URI: env.PLANTUML_URI || null,

+ 1 - 0
lib/routes/page.js

@@ -167,6 +167,7 @@ module.exports = function(crowi, app) {
     Page.hasPortalPage(path, req.user, req.query.revision)
     .then(function(portalPage) {
       renderVars.page = portalPage;
+      renderVars.revision = portalPage.revision;
 
       if (portalPage) {
         return Revision.findRevisionList(portalPage.path, {});

+ 47 - 46
lib/views/_form.html

@@ -13,56 +13,57 @@
   </ul>
 </div>
 {% endif %}
-<div id="form-box" class="row">
-  <form action="/_/edit" id="page-form" method="post" class="col-md-6 {% if isUploadable() %}uploadable{% endif %} page-form">
-    <textarea name="pageForm[body]" class="form-control" id="form-body">{% if pageForm.body %}{{ pageForm.body }}{% elseif not page.revision.body %}# {{ path|path2name }}{% else %}{{ page.revision.body }}{% endif %}</textarea>
 
-    <input type="hidden" name="pageForm[path]" value="{{ path }}">
-    <input type="hidden" name="pageForm[currentRevision]" value="{{ pageForm.currentRevision|default(page.revision._id.toString()) }}">
-    <div class="form-submit-group form-group form-inline">
-      {#<button class="btn btn-default">
-        <i class="fa fa-file-text"></i>
-        ファイルを追加 ...
-      </button>#}
+<form action="/_/edit" id="page-form" method="post" class="{% if isUploadable() %}uploadable{% endif %} page-form">
 
-      <div class="pull-right form-inline page-form-setting" id="page-form-setting" data-slack-configured="{{ slackConfigured() }}">
-        {% if slackConfigured() %}
-        <span class="input-group extended-setting">
-          <span class="input-group-addon">
-            <label>
-              <i class="fa fa-slack"></i>
-              <input class="" type="checkbox" name="pageForm[notify][slack][on]" value="1">
-            </label>
-          </span>
-          <input class="form-control" type="text" name="pageForm[notify][slack][channel]" value="{{ page.extended.slack|default('') }}" placeholder="slack-channel-name"
-            id="page-form-slack-channel"
-            data-toggle="popover"
-            title="Slack通知"
-            data-content="通知するにはチェックを入れてください。カンマ区切りで複数チャンネルに通知することができます。"
-            data-trigger="focus"
-            data-placement="top"
-          >
-        </span>
-        {% endif %}
+  <div id="page-editor">{% if pageForm.body %}{{ pageForm.body }}{% endif %}</div>
+
+  <input type="hidden" id="form-body" name="pageForm[body]" value="{% if pageForm.body %}{{ pageForm.body }}{% endif %}">
+  <input type="hidden" name="pageForm[path]" value="{{ path }}">
+  <input type="hidden" name="pageForm[currentRevision]" value="{{ pageForm.currentRevision|default(page.revision._id.toString()) }}">
+  <div class="form-submit-group form-group form-inline">
+    {#<button class="btn btn-default">
+      <i class="fa fa-file-text"></i>
+      ファイルを追加 ...
+    </button>#}
 
-        {% if forceGrant %}
-        <input type="hidden" name="pageForm[grant]" value="{{ forceGrant }}">
-        {% else %}
-        <select name="pageForm[grant]" class="form-control">
-          {% for grantId, grantLabel in consts.pageGrants %}
-          <option value="{{ grantId }}" {% if pageForm.grant|default(page.grant) == grantId %}selected{% endif %}>{{ t(grantLabel) }}</option>
-          {% endfor %}
-        </select>
-        {% endif %}
-        <input type="hidden" id="edit-form-csrf" name="_csrf" value="{{ csrf() }}">
-        <input type="submit" class="btn btn-primary" id="edit-form-submit" value="{{ t('Update Page') }}" />
-      </div>
+    <div class="pull-left">
+      <div id="page-editor-theme-selector"></div>
     </div>
-  </form>
-  <div class="col-md-6 hidden-sm hidden-xs">
-    <div id="preview-body" class="wiki preview-body">
+
+    <div class="pull-right form-inline page-form-setting" id="page-form-setting" data-slack-configured="{{ slackConfigured() }}">
+      {% if slackConfigured() %}
+      <span class="input-group extended-setting">
+        <span class="input-group-addon">
+          <label>
+            <i class="fa fa-slack"></i>
+            <input class="" type="checkbox" name="pageForm[notify][slack][on]" value="1">
+          </label>
+        </span>
+        <input class="form-control" type="text" name="pageForm[notify][slack][channel]" value="{{ page.extended.slack|default('') }}" placeholder="slack-channel-name"
+          id="page-form-slack-channel"
+          data-toggle="popover"
+          title="Slack通知"
+          data-content="通知するにはチェックを入れてください。カンマ区切りで複数チャンネルに通知することができます。"
+          data-trigger="focus"
+          data-placement="top"
+        >
+      </span>
+      {% endif %}
+
+      {% if forceGrant %}
+      <input type="hidden" name="pageForm[grant]" value="{{ forceGrant }}">
+      {% else %}
+      <select name="pageForm[grant]" class="form-control">
+        {% for grantId, grantLabel in consts.pageGrants %}
+        <option value="{{ grantId }}" {% if pageForm.grant|default(page.grant) == grantId %}selected{% endif %}>{{ t(grantLabel) }}</option>
+        {% endfor %}
+      </select>
+      {% endif %}
+      <input type="hidden" id="edit-form-csrf" name="_csrf" value="{{ csrf() }}">
+      <input type="submit" class="btn btn-primary" id="edit-form-submit" value="{{ t('Update Page') }}" />
     </div>
   </div>
-  <div class="file-module hidden">
-  </div>
+</form>
+<div class="file-module hidden">
 </div>

+ 4 - 49
lib/views/admin/customize.html

@@ -216,7 +216,8 @@
 
         <div class="form-group">
           <div class="col-xs-12">
-            <textarea id="taCustomCss" class="form-control" type="textarea" name="settingForm[customize:css]" rows="20">{{ settingForm['customize:css'] }}</textarea>
+            <div id="custom-css-editor"></div>
+            <input type="hidden" id="inputCustomCss" name="settingForm[customize:css]" value="{{ settingForm['customize:css'] }}">
           </div>
           <div class="col-xs-12">
             <p class="help-block text-right">
@@ -262,7 +263,8 @@
 
         <div class="form-group">
           <div class="col-xs-12">
-            <textarea id="taCustomScript" class="form-control" type="textarea" name="settingForm[customize:script]" rows="20">{{ settingForm['customize:script'] }}</textarea>
+            <div id="custom-script-editor"></div>
+            <input type="hidden" id="inputCustomScript" name="settingForm[customize:script]" value="{{ settingForm['customize:script'] }}">
           </div>
           <div class="col-xs-12">
             <p class="help-block text-right">
@@ -336,53 +338,6 @@
 
   </script>
 
-  <!-- CodeMirror -->
-  <script src="https://cdn.jsdelivr.net/g/codemirror@4.5.0(codemirror.min.js+addon/lint/css-lint.js+addon/lint/javascript-lint.js+mode/css/css.js+mode/javascript/javascript.js+addon/hint/css-hint.js+addon/hint/javascript-hint.js+addon/hint/show-hint.js+addon/edit/matchbrackets.js+addon/edit/closebrackets.js),jquery.ui@1.11.4"></script>
-  <script>
-    // Configure for CSS editor
-    var editorCss = CodeMirror.fromTextArea(document.getElementById('taCustomCss'), {
-      mode: "css",
-      lineNumbers: true,
-      tabSize: 2,
-      indentUnit: 2,
-      theme: 'eclipse',
-      matchBrackets: true,
-      autoCloseBrackets: true,
-      extraKeys: {"Ctrl-Space": "autocomplete"},
-    });
-    editorCss.on('change', function(cm, change) {
-      cm.save();
-    });
-    // resizable with jquery.ui
-    $(editorCss.getWrapperElement()).resizable({
-      resize: function() {
-        editorCss.setSize($(this).width(), $(this).height());
-      }
-    });
-
-    // Configure for JavaScript editor
-    var editorScript = CodeMirror.fromTextArea(document.getElementById('taCustomScript'), {
-      mode: "javascript",
-      lineNumbers: true,
-      tabSize: 2,
-      indentUnit: 2,
-      theme: 'eclipse',
-      matchBrackets: true,
-      autoCloseBrackets: true,
-      extraKeys: {"Ctrl-Space": "autocomplete"},
-    });
-    editorScript.on('change', function(cm, change) {
-      cm.save();
-    });
-    // resizable with jquery.ui
-    $(editorScript.getWrapperElement()).resizable({
-      resize: function() {
-        editorScript.setSize($(this).width(), $(this).height());
-      }
-    });
-
-  </script>
-
 </div>
 {% endblock content_main %}
 

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

@@ -11,7 +11,7 @@
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main">
+<div class="content-main admin-security">
   <div class="row">
     <div class="col-md-3">
       {% include './widget/menu.html' with {current: 'security'} %}

+ 1 - 8
lib/views/crowi-plus/base/not_found_nosidebar.html

@@ -44,12 +44,5 @@
 
 {% block footer %}
   {% parent %}
-  <div class="system-version">
-    <span>
-      <a href="https://github.com/weseek/crowi-plus">crowi-plus</a> {{ crowiVersion() }}
-    </span>
-    <span>
-      <a href="" data-target="#help-modal" data-toggle="modal"><i class="fa fa-question-circle"></i> {{ t('Help') }}</a>
-    </span>
-  </div>
+  {% include '../widget/system-version.html' %}
 {% endblock %}

+ 1 - 8
lib/views/crowi-plus/base/page_list_nosidebar.html

@@ -44,12 +44,5 @@
 
 {% block footer %}
   {% parent %}
-  <div class="system-version">
-    <span>
-      <a href="https://github.com/weseek/crowi-plus">crowi-plus</a> {{ crowiVersion() }}
-    </span>
-    <span>
-      <a href="" data-target="#help-modal" data-toggle="modal"><i class="fa fa-question-circle"></i> {{ t('Help') }}</a>
-    </span>
-  </div>
+  {% include '../widget/system-version.html' %}
 {% endblock %}

+ 1 - 8
lib/views/crowi-plus/base/page_nosidebar.html

@@ -44,12 +44,5 @@
 
 {% block footer %}
   {% parent %}
-  <div class="system-version">
-    <span>
-      <a href="https://github.com/weseek/crowi-plus">crowi-plus</a> {{ crowiVersion() }}
-    </span>
-    <span>
-      <a href="" data-target="#help-modal" data-toggle="modal"><i class="fa fa-question-circle"></i> {{ t('Help') }}</a>
-    </span>
-  </div>
+  {% include '../widget/system-version.html' %}
 {% endblock %}

+ 1 - 8
lib/views/crowi-plus/base/user_page_nosidebar.html

@@ -66,12 +66,5 @@
 
 {% block footer %}
   {% parent %}
-  <div class="system-version">
-    <span>
-      <a href="https://github.com/weseek/crowi-plus">crowi-plus</a> {{ crowiVersion() }}
-    </span>
-    <span>
-      <a href="" data-target="#help-modal" data-toggle="modal"><i class="fa fa-question-circle"></i> {{ t('Help') }}</a>
-    </span>
-  </div>
+  {% include '../widget/system-version.html' %}
 {% endblock %}

+ 3 - 0
lib/views/crowi-plus/page_list.html

@@ -29,6 +29,9 @@
         {# force remove #revision-toc from #content_main of parent #}
         <script>
           $('#revision-toc').remove();
+
+          // hide unportalize button
+          $('.portal > .nav > .dropdown').remove();
         </script>
 
       </div> {# /.col- #}

+ 24 - 0
lib/views/crowi-plus/widget/system-version.html

@@ -0,0 +1,24 @@
+<div class="system-version">
+  <span>
+    <a href="https://github.com/weseek/crowi-plus">crowi-plus</a> {{ crowiVersion() }}
+  </span>
+  <span>
+    <a href="" data-target="#shortcuts-modal" data-toggle="modal"><i class="fa fa-keyboard-o"></i>&nbsp;<span class="cmd-key"></span>-/</a>
+  </span>
+</div>
+<script>
+  /*
+  * add classes to cmd-key by OS
+  */
+  var platform = navigator.platform.toLowerCase();
+  var isMac = (platform.indexOf('mac') > -1);
+
+  document.querySelectorAll('.system-version .cmd-key').forEach((element) => {
+    if (isMac) {
+      element.classList.add('mac');
+    }
+    else {
+      element.classList.add('win');
+    }
+  });
+</script>

+ 1 - 1
lib/views/layout/2column.html

@@ -19,8 +19,8 @@
   <div id="footer-container" class="footer">
     <footer class="">
       <p>
-        <a href="" data-target="#help-modal" data-toggle="modal"><i class="fa fa-question-circle"></i> {{ t('Help') }}</a>
         <a href="https://github.com/weseek/crowi-plus">crowi-plus</a> {{ crowiVersion() }}
+        <a href="" class="pull-right" data-target="#shortcuts-modal" data-toggle="modal"><i class="fa fa-keyboard-o"></i>&nbsp;<span class="cmd-key"></span>-/</a>
       </p>
     </footer>
   </div>

+ 6 - 4
lib/views/layout/layout.html

@@ -33,8 +33,8 @@
     }
   </script>
 
-  <!-- jQuery -->
-  <script src="https://cdn.jsdelivr.net/jquery/3.2.1/jquery.min.js"></script>
+  <!-- jQuery, emojione -->
+  <script src="https://cdn.jsdelivr.net/combine/npm/emojione@3.1.2,npm/jquery@3.3.1"></script>
 
   {% if env === 'development' %}
     <script src="/dll/vendor.dll.js"></script>
@@ -54,7 +54,9 @@
   <!-- Google Fonts -->
   <link href='https://fonts.googleapis.com/css?family=Lato:400,700' rel='stylesheet' type='text/css'>
   <!-- Font Awesome -->
-  <link href='https://cdn.jsdelivr.net/fontawesome/4.7.0/css/font-awesome.min.css' rel='stylesheet' type='text/css'>
+  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css">
+  <!-- emojione -->
+  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/emojione@3.1.2/extras/css/emojione.min.css">
 
   {% block html_additional_headers %}{% endblock %}
 
@@ -176,7 +178,7 @@
 {% block body_end %}
 {% endblock %}
 
-{% include '../modal/help.html' %}
+{% include '../modal/shortcuts.html' %}
 </body>
 {% endblock %}
 

+ 17 - 1
lib/views/layout/single.html

@@ -23,7 +23,23 @@
     <a href="https://github.com/weseek/crowi-plus">crowi-plus</a> {{ crowiVersion() }}
   </span>
   <span>
-    <a href="" data-target="#help-modal" data-toggle="modal"><i class="fa fa-question-circle"></i> {{ t('Help') }}</a>
+    <a href="" data-target="#shortcuts-modal" data-toggle="modal"><i class="fa fa-keyboard-o"></i>&nbsp;<span class="cmd-key"></span>-/</a>
   </span>
 </div>
+<script>
+  /*
+  * add classes to cmd-key by OS
+  */
+  var platform = navigator.platform.toLowerCase();
+  var isMac = (platform.indexOf('mac') > -1);
+
+  document.querySelectorAll('.system-version .cmd-key').forEach((element) => {
+    if (isMac) {
+      element.classList.add('mac');
+    }
+    else {
+      element.classList.add('win', 'key-longer');
+    }
+  })
+</script>
 {% endblock %}

+ 0 - 113
lib/views/modal/help.html

@@ -1,113 +0,0 @@
-<div class="modal" id="help-modal">
-  <div class="modal-dialog">
-    <div class="modal-content">
-
-      <div class="modal-header">
-        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-        <h4 class="modal-title">{{ t('Help') }}</h4>
-      </div>
-      <div class="modal-body">
-        <h4>{{ t('modal_help.basic.title') }}</h4>
-        <br>
-        <ul>
-          <li>{{ t('modal_help.basic.body1') }}</li>
-          <li>{{ t('modal_help.basic.body2') }}</li>
-          <li>{{ t('modal_help.basic.body3') }}</li>
-        </ul>
-        <br>
-
-        <h4>{{ t('modal_help.tips.title') }}</h4>
-        <br>
-        <p>
-        {{ t('modal_help.tips.body1') }}
-        </p>
-        <br>
-
-        <h4>{{ t('modal_help.markdown.title') }}</h4>
-        <br>
-        <div class="wiki">
-        <pre># Section</pre>
-        <h1>Section</h1>
-        </div>
-        <hr>
-
-        <div class="wiki">
-        <pre>## Sub section</pre>
-        <h2>Sub Section</h2>
-        </div>
-        <hr>
-
-        <div class="wiki">
-        <pre>### Sub sub section</pre>
-        <h3>Sub Sub Section</h3>
-        </div>
-        <hr>
-
-        <div class="wiki">
-        <pre>- このようにハイフンと半角スペースを先頭に書くと、
-- 箇条書きのリストにになります
-    - タブキーを押すと半角スペース4つが挿入され、インデントされます
-    - インデントはリストにも反映されます</pre>
-          <ul>
-            <li>リスト記法はこのように</li>
-            <li>箇条書きになります
-            <ul>
-              <li>タブキーを押すと半角スペース4つが挿入され、インデントされます</li>
-              <li>インデントはリストにも反映されます</li>
-            </ul>
-            </li>
-          </ul>
-        </div>
-        <hr>
-
-        <div class="wiki">
-        <pre>1. 番号付きリストも作れます
-2. "数字" "ドット" "半角スペース" の後に項目を記載しましょう
-2. "1." "2." "2." などと数字がズレても、正しい数字で整形されます</pre>
-          <ol>
-            <li>番号付きリストも作れます</li>
-            <li>"数字" "ドット" "半角スペース" の後に項目を記載しましょう</li>
-            <li>"1." "2." "2." などと数字がズレても、正しい数字で整形されます</li>
-          </ol>
-        </div>
-        <hr>
-
-        <div class="wiki">
-        <pre>**アスタリスク2つで囲った箇所は** 太字になります</pre>
-        <p><strong>アスタリスク2つで囲った箇所は</strong> 太字になります</p>
-        </div>
-        <hr>
-
-        <div class="wiki">
-        <pre>Wiki内リンクは &lt;/とある/ページへの/リンク&gt; のように、 &lt; と &gt; で囲います</pre>
-        <p>Wiki内リンクは <a href="/とある/ページへの/リンク">/とある/ページへの/リンク</a> のように、 &lt; と &gt; で囲います</p>
-        </div>
-        <hr>
-
-        <h4>プレゼンモード</h4>
-        <br>
-        <p>
-        文章をきちんと構造的に作成していれば、自然とプレゼンモードが適用できます。ページの <code><i class="fa"></i></code> アイコンをクリックし、プレゼンモードを選択してください。
-        </p>
-        <p>
-        プレゼンモードでは、<strong>改行2つ</strong> をページ区切りとして利用します。プレゼンモードでページを区切りたい場合、2つの改行を挿入してください。
-        </p>
-
-        <p>例:</p>
-        <div class="wiki">
-          <pre># 改行したいプレゼンの説明
-
-何かしらの説明
-
-
-## 次の章
-
-ここでは、「次の章」の前に2つの改行があるため、「次の章」以降が1つのプレゼンページになります。 </pre>
-        </div>
-        <br>
-
-      </div>
-
-    </div><!-- /.modal-content -->
-  </div><!-- /.modal-dialog -->
-</div><!-- /.modal -->

+ 79 - 0
lib/views/modal/shortcuts.html

@@ -0,0 +1,79 @@
+<div class="modal" id="shortcuts-modal">
+  <div class="modal-dialog">
+    <div class="modal-content">
+
+      <div class="modal-header">
+        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+        <h4 class="modal-title">{{ t('Shortcuts') }}</h4>
+      </div>
+
+      <div class="modal-body">
+
+        <div class="row">
+          <div class="col-sm-6">
+            <h3><strong>{{ t('modal_shortcuts.global.title') }}</strong></h3>
+
+            <table class="table">
+              <tr>
+                <th>{{ t('modal_shortcuts.global.Open/Close shortcut help') }}:</th>
+                <td><span class="key cmd-key"></span> + <span class="key">/</span></td>
+              </tr>
+              <tr>
+                <th>{{ t('modal_shortcuts.global.Create Page') }}:</th>
+                <td><span class="key">C</span></td>
+              </tr>
+              <tr>
+                <th>{{ t('modal_shortcuts.global.Edit Page') }}:</th>
+                <td><span class="key">E</span></td>
+              </tr>
+            </table>
+          </div><!-- /.col-sm-6 -->
+
+          <div class="col-sm-6">
+            <h3><strong>{{ t('modal_shortcuts.editor.title') }}</strong></h3>
+
+            <table class="table">
+              <tr>
+                <th>{{ t('modal_shortcuts.editor.Indent') }}:</th>
+                <td><span class="key key-longer">Tab</span></td>
+              </tr>
+              <tr>
+                <th>{{ t('modal_shortcuts.editor.Outdent') }}:</th>
+                <td><span class="key key-long">Shift</span> + <span class="key key-longer">Tab</span></td>
+              </tr>
+              <tr>
+                <th>{{ t('modal_shortcuts.editor.Save Page') }}:</th>
+                <td><span class="key cmd-key"></span> + <span class="key">S</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>
+
+      </div>
+
+
+    </div><!-- /.modal-content -->
+  </div><!-- /.modal-dialog -->
+
+  <script>
+    /*
+     * add classes to cmd-key by OS
+     */
+    var platform = navigator.platform.toLowerCase();
+    var isMac = (platform.indexOf('mac') > -1);
+
+    document.querySelectorAll('#shortcuts-modal .cmd-key').forEach((element) => {
+      if (isMac) {
+        element.classList.add('mac');
+      }
+      else {
+        element.classList.add('win', 'key-longer');
+      }
+    });
+  </script>
+</div><!-- /.modal -->

+ 0 - 5
lib/views/not_found.html

@@ -36,12 +36,7 @@
 <div id="content-main" class="content-main content-main-not-found page-list"
   data-path="{{ path }}"
   data-path-shortname="{{ path|path2name }}"
-  data-page-id="{% if page %}{{ page._id.toString() }}{% endif %}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
-  data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
-  data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
-  data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
-  data-linebreaks-enabled="{{ isEnabledLinebreaks() }}"
   >
 
   <ul class="nav nav-tabs hidden-print">

+ 1 - 2
lib/views/page.html

@@ -57,7 +57,6 @@
   data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
   data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
-  data-linebreaks-enabled="{{ isEnabledLinebreaks() }}"
   >
 
   {% if not page %}
@@ -164,7 +163,7 @@
   </div>
   {% endif %}
   {% if req.query.unlinked %}
-  <div class="alert alert-info">
+  <div class="alert alert-info alert-unlinked">
     <strong>{{ t('Unlinked') }}: </strong> {{ t('page_page.notice.unlinked') }}
   </div>
   {% endif %}

+ 0 - 1
lib/views/page_list.html

@@ -80,7 +80,6 @@
   data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
   data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
-  data-linebreaks-enabled="{{ isEnabledLinebreaks() }}"
   >
 
 <div class="portal {% if not page or req.query.offset > 0 %}hide{% endif %}">

+ 15 - 10
package.json

@@ -1,6 +1,6 @@
 {
   "name": "crowi-plus",
-  "version": "2.3.4-RC",
+  "version": "2.3.8-RC",
   "description": "Enhanced Crowi",
   "tags": [
     "wiki",
@@ -42,7 +42,7 @@
     "server:prod": "env-cmd config/env.prod.js node app.js --production | pino-clf common",
     "server": "npm run server:dev:watch",
     "start": "npm run server:prod",
-    "test": "mocha -r test/bootstrap.js test/**/*.js",
+    "test": "mocha --timeout 10000 -r test/bootstrap.js test/**/*.js",
     "version": "node -p \"require('./package.json').version\"",
     "webpack": "webpack"
   },
@@ -60,6 +60,7 @@
     "body-parser": "^1.18.2",
     "bootstrap-sass": "~3.3.6",
     "check-node-version": "^3.1.1",
+    "codemirror": "^5.33.0",
     "connect-flash": "~0.1.1",
     "connect-redis": "^3.3.0",
     "cookie-parser": "^1.4.3",
@@ -72,7 +73,7 @@
     "diff": "^3.3.0",
     "diff2html": "^2.3.0",
     "elasticsearch": "^14.0.0",
-    "emojione": "^3.1.2",
+    "entities": "^1.1.1",
     "env-cmd": "^7.0.0",
     "escape-string-regexp": "^1.0.5",
     "express": "^4.16.1",
@@ -81,22 +82,23 @@
     "express-session": "~1.15.0",
     "express-webpack-assets": "^0.1.0",
     "file-loader": "^1.1.0",
-    "googleapis": "^23.0.0",
+    "get-line-from-pos": "^1.0.0",
+    "googleapis": "^25.0.0",
     "graceful-fs": "^4.1.11",
     "highlight.js": "^9.10.0",
     "i18next": "^10.0.1",
     "i18next-express-middleware": "^1.0.5",
     "i18next-node-fs-backend": "^1.0.0",
     "i18next-sprintf-postprocessor": "^0.2.2",
-    "inline-attachment": "~2.0.3",
+    "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
     "marked": "^0.3.12",
     "md5": "^2.2.1",
     "method-override": "^2.3.10",
     "mkdirp": "~0.5.1",
-    "mongoose": "^4.13.5",
-    "mongoose-paginate": "5.0.x",
-    "mongoose-unique-validator": "^1.0.6",
+    "mongoose": "^5.0.0",
+    "mongoose-paginate": "^5.0.0",
+    "mongoose-unique-validator": "^2.0.0",
     "multer": "~1.3.0",
     "node-sass": "^4.5.0",
     "nodemailer": "^4.0.1",
@@ -112,15 +114,18 @@
     "react-bootstrap": "^0.32.0",
     "react-bootstrap-typeahead": "^2.0.2",
     "react-clipboard.js": "^1.1.2",
+    "react-codemirror2": "^3.0.7",
     "react-dom": "^16.0.0",
+    "react-dropzone": "^4.2.7",
     "redis": "^2.7.1",
     "reveal.js": "^3.5.0",
     "rimraf": "^2.6.1",
     "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",
     "uglifycss": "^0.0.27",
     "webpack": "^3.1.0",
@@ -134,7 +139,7 @@
     "colors": "^1.1.2",
     "commander": "^2.11.0",
     "easy-livereload": "^1.2.0",
-    "mocha": "^4.0.0",
+    "mocha": "^5.0.0",
     "morgan": "^1.8.2",
     "node-dev": "^3.1.3",
     "on-headers": "^1.0.1",

+ 32 - 29
resource/css/_admin.scss

@@ -1,7 +1,7 @@
 // import crowi variable
 @import 'utilities';
 
-.crowi { // {{{
+.crowi {
 
   .admin-user-menu {
     .dropdown-menu {
@@ -10,45 +10,48 @@
     }
   }
 
-  .content-main.admin-customize {
+  .admin-customize {
     .ss-container img {
       padding: .5em;
       background-color: #ddd;
     }
-  }
-
-  // Toggle Twitter Bootstrap button class when active
-  // https://jsfiddle.net/ms040m01/3/
-  .btn-group.btn-toggle {
-    .btn.active[data-active-class="primary"] {
-      color: $btn-primary-color;
-      background-color: darken($btn-primary-bg, 10%);
-      border-color: $btn-primary-border;
 
-      &:hover {
-        background-color: darken($btn-primary-bg, 15%);
+    // Toggle Twitter Bootstrap button class when active
+    // https://jsfiddle.net/ms040m01/3/
+    .btn-group.btn-toggle {
+      .btn.active[data-active-class="primary"] {
+        color: $btn-primary-color;
+        background-color: darken($btn-primary-bg, 10%);
+        border-color: $btn-primary-border;
+
+        &:hover {
+          background-color: darken($btn-primary-bg, 15%);
+        }
       }
     }
-  }
 
-  .table-user-list {
-    .label-admin {
-      margin-left: 1em;
+    .table-user-list {
+      .label-admin {
+        margin-left: 1em;
+      }
     }
-  }
 
-  // override CodeMirror styles
-  .CodeMirror pre {
-    font-family: $font-family-monospace;
+    // override CodeMirror styles
+    .CodeMirror pre {
+      font-family: $font-family-monospace;
+    }
   }
 
-  .passport-logo {
-    padding: 4px;
-    height: 32px;
-    background-color: black;
-  }
+  .admin-security {
+    .passport-logo {
+      padding: 4px;
+      height: 32px;
+      background-color: black;
+    }
 
-  .auth-mechanism-configurations {
-    min-height: 800px;
+    .auth-mechanism-configurations {
+      min-height: 800px;
+    }
   }
-} // }}}
+  
+}

+ 180 - 66
resource/css/_form.scss

@@ -24,82 +24,179 @@
   .tab-content {
     top: 48px;
     bottom: 58px;
-    padding: 0 12px;
     position: absolute;
     z-index: 1051;
     left: 0;
     right: 0;
     margin-top: 4px;
 
-    .alert-info {
+    .alert-info.alert-moved,
+    .alert-info.alert-unlinked {
       display: none;
     }
 
-    .edit-form { // {{{
+    // layout (height: 100%)
+    .edit-form {
       height: 100%;
-      > .row {
+      > form {
         height: 100%;
-        .col-md-6 {
+        > #page-editor {
           height: 100%;
-        }
-        form {
-          padding: 0;
-          border-right: solid 1px #ccc;
-          &::after {
-            position: absolute;
-            top: 0;
-            right: 15px;
-            font-size: 10px;
-            font-weight: 700;
-            color: #959595;
-            text-transform: uppercase;
-            letter-spacing: 1px;
-            content: "Input Content ...";
+
+          .row, .col-md-6, .col-sm-12,
+          .react-codemirror2, .CodeMirror, .CodeMirror-scroll,
+          .page-editor-preview-body
+          {
+            height: 100%;
           }
         }
-        textarea {
-          height: 100%;
-          padding-top: 18px;
-          border: none;
-          box-shadow: none;
+      }
+    }
+  }
 
-          &.dragover {
-            border: dashed 6px #ccc;
-            padding: 12px 6px 0px;
-          }
+  .page-form-setting {
+    .extended-setting {
+      label {
+        margin-bottom: 0;
+      }
+    }
+  }
+
+  .page-editor-editor-container {
+    padding-right: 0;
+    border-right: 1px solid #ccc;
+
+    // override CodeMirror styles
+    .CodeMirror {
+      pre {
+        font-family: $font-family-monospace-not-strictly;
+      }
+      .cm-matchhighlight {
+        background-color: cyan;
+      }
+      .CodeMirror-selection-highlight-scrollbar {
+        background-color: darkcyan;
+      }
+    }
+
+    // for Dropzone
+    .dropzone {
+      @mixin insertFontAwesome($code) {
+        &:before {
+          margin-right: 0.2em;
+          font-family: FontAwesome;
+          content: $code;
         }
-        .preview-body {
-          height: 100%;
-          padding-top: 18px;
-          overflow: scroll;
-
-          &::after {
-            position: absolute;
-            top: 0;
-            right: 15px;
-            font-size: 10px;
-            font-weight: 700;
-            color: #959595;
-            text-transform: uppercase;
-            letter-spacing: 1px;
-            content: "Preview";
-          }
+      }
+
+      // default layout and style
+      .dropzone-overlay {
+        // layout
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        // style
+        margin: 0 15px;
+      }
+      .dropzone-overlay-content {
+        font-size: 2em;
+        padding: 0.2em;
+        border-radius: 5px;
+      }
+
+      // unuploadable or rejected
+      &.dropzone-unuploadable, &.dropzone-rejected {
+        .dropzone-overlay {
+          background: rgba(200,200,200,0.8);
         }
+        .dropzone-overlay-content {
+          color: #444;
+        }
+      }
+      // uploading
+      &.dropzone-uploading {
+        .dropzone-overlay {
+          background: rgba(255,255,255,0.5);
+        }
+        .dropzone-overlay-content {
+          padding: 0.3em;
+          background: rgba(200,200,200,0.5);
+          color: #444;
+        }
+      }
 
-        .page-form-setting {
-          .extended-setting {
-            label {
-              margin-bottom: 0;
+      // unuploadable
+      &.dropzone-unuploadable {
+        .dropzone-overlay-content {
+          // insert content
+          @include insertFontAwesome("\f06a ");  // fa-exclamation-circle
+          &:after {
+            content: "File uploading is disabled";
+          }
+        }
+      }
+      // uploadable
+      &.dropzone-uploadable {
+        // accepted
+        &.dropzone-accepted:not(.dropzone-rejected) {
+          .dropzone-overlay {
+            border: 4px dashed #ccc;
+          }
+          .dropzone-overlay-content {
+            // insert content
+            @include insertFontAwesome("\f093");  // fa-upload
+            &:after {
+              content: "Drop here to upload";
             }
+            // style
+            color: #666;
+            background: rgba(200,200,200,0.8);
+          }
+        }
+        // file type mismatch
+        &.dropzone-rejected:not(.dropzone-uploadablefile) .dropzone-overlay-content {
+          // insert content
+          @include insertFontAwesome("\f03e");  // fa-picture-o
+          &:after {
+            content: "Only an image file is allowed";
+          }
+        }
+        // multiple files
+        &.dropzone-accepted.dropzone-rejected .dropzone-overlay-content {
+          // insert content
+          @include insertFontAwesome("\f071");  // fa-fa-exclamation-triangle
+          &:after {
+            content: "Only 1 file is allowed";
           }
         }
       }
+    } // end of.dropzone
+
+    .btn-open-dropzone {
+      font-size: small;
+      text-align: right;
+      padding-top: 3px;
+      padding-bottom: 0;
+      border: none;
+      border-radius: 0;
+      border-top: 1px dotted #ccc;
+      background-color: transparent;
+
+      &:active {
+        box-shadow: none;
+      }
     }
+  }
+  .page-editor-preview-container {
+  }
 
-  } // }}}
+  .page-editor-preview-body {
+    padding-top: 18px;
+    padding-right: 15px;
+    overflow-y: scroll;
+  }
 
   .form-group.form-submit-group {
-
     position: fixed;
     z-index: 1054;
     bottom: 0;
@@ -111,6 +208,12 @@
     border-top: solid 1px #ccc;
     margin-bottom: 0;
   }
+
+  #page-editor-theme-selector .theme-selector {
+    label {
+      margin-right: 0.5em;
+    }
+  }
 } // }}}
 
 .crowi.main-container .main .page-list.content-main { // {{{ Edit Form of Page List
@@ -127,10 +230,6 @@
   .page-list-container {
     display: none;
   }
-  .portal-form-header,
-  .portal-form {
-    display: block;
-  }
 
   .portal-form-header {
     height: 16px;
@@ -139,18 +238,6 @@
   }
 } // }}}
 
-textarea {
-  font-family: $font-family-monospace-not-strictly;
-  line-height: 1.1em;
-}
-
-input::-webkit-input-placeholder {
-  color: #ccc;
-}
-input:-moz-placeholder {
-  color: #ccc;
-}
-
 @media (max-width: $screen-sm-max) { // {{{ less than tablet size
 
   .content-main.on-edit {
@@ -169,3 +256,30 @@ input:-moz-placeholder {
     float: right;
   }
 } // }}}
+
+// overwrite .CodeMirror-hints
+.CodeMirror-hints {
+  // FIXME: required because .content-main.on-edit has 'z-index:1050'
+  z-index: 1060 !important;
+
+  max-height: 30em !important;
+
+  .CodeMirror-hint.crowi-emoji-autocomplete {
+    font-family: $font-family-monospace-not-strictly;
+    line-height: 1.6em;
+
+    .img-container {
+      display: inline-block;
+      width: 30px;
+    }
+  }
+  // active line
+  .CodeMirror-hint-active.crowi-emoji-autocomplete {
+    .img-container {
+      font-size: 15px;  // adjust to .wiki
+      padding-top: 0.3em;
+      padding-bottom: 0.3em;
+    }
+  }
+
+}

+ 11 - 11
resource/css/_layout_crowi-plus.scss

@@ -3,20 +3,20 @@
     padding: 0;
   }
 
-  .system-version {
-    position: fixed;
-    right: 0;
-    bottom: 0;
-    opacity: .6;
-
-    > span {
-      margin-left: .5em;
-    }
-  }
-
   @media (max-width: $screen-sm-max) {
     .system-version {
       display: none;
     }
   }
 }
+
+.system-version {
+  position: fixed;
+  right: 0.5em;
+  bottom: 0;
+  opacity: .6;
+
+  > span {
+    margin-left: .5em;
+  }
+}

+ 2 - 2
resource/css/_page.scss

@@ -125,7 +125,7 @@
 
       // alert component settings for trash page and moved page
       // see: https://jsfiddle.net/me420sky/2/
-      .alert-trash, .alert-moved {
+      .alert-trash, .alert-moved, .alert-unlinked {
         padding: 10px 15px;
 
         span {
@@ -267,7 +267,7 @@
 
   .footer { // {{{
     position: fixed;
-    width: 100%;
+    width: calc(25% - 18px);
     bottom: 0px;
     height: 26px;
     padding: 4px;

+ 66 - 0
resource/css/_shortcuts.scss

@@ -0,0 +1,66 @@
+// import crowi variable
+@import 'utilities';
+
+#shortcuts-modal {
+  @media (min-width: $screen-sm-min) { // {{{ more than tablet size
+    .modal-dialog {
+      width: 750px;
+    }
+  }
+  @media (max-width: $screen-xs-max) { // {{{ less than tablet size
+    table {
+      table-layout: fixed;
+    }
+  }
+
+  h3 {
+    margin-bottom: 1em;
+  }
+
+  table {
+    th {
+      vertical-align: middle;
+    }
+    td {
+      min-width: 170px;
+    }
+  }
+
+  // see http://coliss.com/articles/build-websites/operation/css/css-apple-keyboard-style-by-nrjmadan.html
+  .key {
+    /*Box Properties*/
+    display:inline-block;
+    width:36px;
+    height:36px;
+    margin: 0px 4px;
+    background: #fff;
+    border-radius: 4px;
+    box-shadow: 0px 1px 3px 1px rgba(0, 0, 0, 0.5);
+    /*Text Properties*/
+    font: 18px/36px Helvetica, serif ;
+    text-transform: uppercase;
+    text-align: center;
+    color: #666;
+
+    &.key-longer {
+      width: 64px;
+    }
+    &.key-long {
+      width: 72px;
+    }
+  }
+
+  .dl-horizontal {
+    dt {
+      // width: 180px;
+      height: 41px;
+      display: flex;
+      justify-content: flex-end;
+      align-items: center;
+    }
+    // dd {
+    //   margin-left: 190px;
+    // }
+  }
+
+}

+ 2 - 4
resource/css/_wiki.scss

@@ -145,12 +145,10 @@ div.body {
   }
 
   img.emojione {
-    width: 1.1em;
-    margin: 1px;
+    margin-top: -0.3em !important;
+    margin-bottom: 0 !important;
     border: none;
     box-shadow: none;
-    vertical-align: middle;
-    display: inline;
   }
 
   ul, ol {

+ 13 - 1
resource/css/crowi.scss

@@ -22,6 +22,7 @@ $bootstrap-sass-asset-helper: true;
 @import 'page_crowi-plus';
 @import 'portal';
 @import 'search';
+@import 'shortcuts';
 @import 'user';
 @import 'user_crowi-plus';
 @import 'wiki';
@@ -116,7 +117,7 @@ footer {
 
 .modal.create-page {
 
-  @media (min-width: 768px) {
+  @media (min-width: $screen-sm-min) { // {{{ more than tablet size
     .modal-dialog {
       width: 750px;
     }
@@ -462,3 +463,14 @@ input.searching {
     border: 0;
   }
 }
+
+.cmd-key.mac {
+  &:after {
+    content: '⌘';
+  }
+}
+.cmd-key.win {
+  &:after {
+    content: 'Ctrl';
+  }
+}

+ 71 - 4
resource/js/app.js

@@ -6,6 +6,8 @@ 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 PageListSearch   from './components/PageListSearch';
 import PageHistory      from './components/PageHistory';
 import PageComments     from './components/PageComments';
@@ -18,6 +20,12 @@ 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';
+
+import * as entities from 'entities';
+
+
 if (!window) {
   window = {};
 }
@@ -27,11 +35,11 @@ let pageId = null;
 let pageRevisionId = null;
 let pageRevisionCreatedAt = null;
 let pagePath;
-let pageContent = null;
+let pageContent = '';
 if (mainContent !== null) {
-  pageId = mainContent.attributes['data-page-id'].value;
-  pageRevisionId = mainContent.attributes['data-page-revision-id'].value;
-  pageRevisionCreatedAt = +mainContent.attributes['data-page-revision-created'].value;
+  pageId = mainContent.getAttribute('data-page-id');
+  pageRevisionId = mainContent.getAttribute('data-page-revision-id');
+  pageRevisionCreatedAt = +mainContent.getAttribute('data-page-revision-created');
   pagePath = mainContent.attributes['data-path'].value;
   const rawText = document.getElementById('raw-text-original');
   if (rawText) {
@@ -58,6 +66,11 @@ if (isEnabledPlugins) {
   crowiPlugin.installAll(crowi, crowiRenderer);
 }
 
+// for PageEditor
+const onSaveSuccess = function(page) {
+  crowi.getCrowiForJquery().updateCurrentRevision(page.revision._id);
+}
+
 /**
  * define components
  *  key: id of element
@@ -97,7 +110,61 @@ if (elem) {
   ReactDOM.render(<PageCommentFormBehavior crowi={crowi} pageComments={componentInstances['page-comments-list']} />, 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}
+        onSaveSuccess={onSaveSuccess} />,
+    pageEditorElem
+  );
+}
+// render ThemeSelector
+const themeSelectorElem = document.getElementById('page-editor-theme-selector');
+if (themeSelectorElem) {
+  ReactDOM.render(
+    <ThemeSelector value={editorTheme}
+        onChange={(value) => { // set onChange event handler
+          pageEditor.setEditorTheme(value);
+          crowi.saveEditorTheme(value);
+        }} />,
+    themeSelectorElem
+  );
+}
+
+// render for admin
+const customCssEditorElem = document.getElementById('custom-css-editor');
+if (customCssEditorElem != null) {
+  // get input[type=hidden] element
+  const customCssInputElem = document.getElementById('inputCustomCss');
+
+  ReactDOM.render(
+    <CustomCssEditor inputElem={customCssInputElem} />,
+    customCssEditorElem
+  )
+}
+const customScriptEditorElem = document.getElementById('custom-script-editor');
+if (customScriptEditorElem != null) {
+  // get input[type=hidden] element
+  const customScriptInputElem = document.getElementById('inputCustomScript');
+
+  ReactDOM.render(
+    <CustomScriptEditor inputElem={customScriptInputElem} />,
+    customScriptEditorElem
+  )
+}
+
 // うわーもうー
 $('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']);

+ 61 - 0
resource/js/components/Admin/CustomCssEditor.js

@@ -0,0 +1,61 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { UnControlled as CodeMirror } from 'react-codemirror2';
+require('codemirror/lib/codemirror.css');
+require('codemirror/addon/display/autorefresh');
+require('codemirror/addon/lint/css-lint');
+require('codemirror/addon/hint/css-hint');
+require('codemirror/addon/hint/show-hint');
+require('codemirror/addon/edit/matchbrackets');
+require('codemirror/addon/edit/closebrackets');
+require('codemirror/mode/css/css');
+require('codemirror/theme/eclipse.css');
+
+require('jquery-ui/ui/widgets/resizable');
+
+export default class CustomCssEditor extends React.Component {
+
+  constructor(props) {
+    super(props);
+  }
+
+  render() {
+    // get initial value from inputElem
+    const value = this.props.inputElem.value;
+
+    return (
+      <CodeMirror
+        value={value}
+        autoFocus={true}
+        options={{
+          mode: 'css',
+          lineNumbers: true,
+          tabSize: 2,
+          indentUnit: 2,
+          theme: 'eclipse',
+          autoRefresh: true,
+          matchBrackets: true,
+          autoCloseBrackets: true,
+          extraKeys: {"Ctrl-Space": "autocomplete"},
+        }}
+        editorDidMount={(editor, next) => {
+          // resizable with jquery.ui
+          $(editor.getWrapperElement()).resizable({
+            resize: function() {
+              editor.setSize($(this).width(), $(this).height());
+            }
+          });
+        }}
+        onChange={(editor, data, value) => {
+          this.props.inputElem.value = value;
+        }}
+      />
+    )
+  }
+
+}
+
+CustomCssEditor.propTypes = {
+  inputElem: PropTypes.object.isRequired,
+};

+ 61 - 0
resource/js/components/Admin/CustomScriptEditor.js

@@ -0,0 +1,61 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { UnControlled as CodeMirror } from 'react-codemirror2';
+require('codemirror/lib/codemirror.css');
+require('codemirror/addon/display/autorefresh');
+require('codemirror/addon/lint/javascript-lint');
+require('codemirror/addon/hint/javascript-hint');
+require('codemirror/addon/hint/show-hint');
+require('codemirror/addon/edit/matchbrackets');
+require('codemirror/addon/edit/closebrackets');
+require('codemirror/mode/javascript/javascript');
+require('codemirror/theme/eclipse.css');
+
+require('jquery-ui/ui/widgets/resizable');
+
+export default class CustomScriptEditor extends React.Component {
+
+  constructor(props) {
+    super(props);
+  }
+
+  render() {
+    // get initial value from inputElem
+    const value = this.props.inputElem.value;
+
+    return (
+      <CodeMirror
+        value={value}
+        autoFocus={true}
+        options={{
+          mode: 'javascript',
+          lineNumbers: true,
+          tabSize: 2,
+          indentUnit: 2,
+          theme: 'eclipse',
+          autoRefresh: true,
+          matchBrackets: true,
+          autoCloseBrackets: true,
+          extraKeys: {"Ctrl-Space": "autocomplete"},
+        }}
+        editorDidMount={(editor, next) => {
+          // resizable with jquery.ui
+          $(editor.getWrapperElement()).resizable({
+            resize: function() {
+              editor.setSize($(this).width(), $(this).height());
+            }
+          });
+        }}
+        onChange={(editor, data, value) => {
+          this.props.inputElem.value = value;
+        }}
+      />
+    )
+  }
+
+}
+
+CustomScriptEditor.propTypes = {
+  inputElem: PropTypes.object.isRequired,
+};

+ 3 - 1
resource/js/components/HeaderSearchBox/SearchForm.js

@@ -1,5 +1,7 @@
 import React from 'react';
-import { FormGroup, Button, InputGroup } from 'react-bootstrap';
+import FormGroup from 'react-bootstrap/es/FormGroup';
+import Button from 'react-bootstrap/es/Button';
+import InputGroup from 'react-bootstrap/es/InputGroup';
 
 import SearchTypeahead from '../SearchTypeahead';
 

+ 2 - 2
resource/js/components/Page/RevisionUrl.js

@@ -17,7 +17,7 @@ export default class RevisionUrl extends React.Component {
       fontSize: "1em"
     }
 
-    const url = (this.props.pageId === '')
+    const url = (this.props.pageId == null)
         ? decodeURIComponent(location.href)
         : `${location.origin}/${this.props.pageId}`;
     const copiedText = this.props.pagePath + '\n' + url;
@@ -33,6 +33,6 @@ export default class RevisionUrl extends React.Component {
 }
 
 RevisionUrl.propTypes = {
-  pageId: PropTypes.string.isRequired,
+  pageId: PropTypes.string,
   pagePath: PropTypes.string.isRequired,
 };

+ 6 - 5
resource/js/components/PageAttachment/DeleteAttachmentModal.js

@@ -1,5 +1,6 @@
 import React from 'react';
-import { Button, Modal } from 'react-bootstrap';
+import Button from 'react-bootstrap/es/Button';
+import Modal from 'react-bootstrap/es/Modal';
 
 import Icon from '../Common/Icon';
 import User from '../User/User';
@@ -18,12 +19,12 @@ export default class DeleteAttachmentModal extends React.Component {
   renderByFileFormat(attachment) {
     if (attachment.fileFormat.match(/image\/.+/i)) {
       return (
-        <p className="attachment-delete-image">
-          <span>
+        <div className="attachment-delete-image">
+          <p>
             {attachment.originalName} uploaded by <User user={attachment.creator} username />
-          </span>
+          </p>
           <img src={attachment.url} />
-        </p>
+        </div>
       );
     }
 

+ 3 - 1
resource/js/components/PageComment/DeleteCommentModal.js

@@ -1,7 +1,9 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { Button, Modal } from 'react-bootstrap';
+import Button from 'react-bootstrap/es/Button';
+import Modal from 'react-bootstrap/es/Modal';
+
 import dateFnsFormat from 'date-fns/format';
 
 import ReactUtils from '../ReactUtils';

+ 1 - 1
resource/js/components/PageComments.js

@@ -165,7 +165,7 @@ export default class PageComments extends React.Component {
       </div>
     );
     const newerBlock = (
-      <div className="page-comments-list-newer collapse" id="page-comments-list-newer">
+      <div className="page-comments-list-newer collapse in" id="page-comments-list-newer">
         {newerElements}
       </div>
     );

+ 315 - 0
resource/js/components/PageEditor.js

@@ -0,0 +1,315 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import * as toastr from 'toastr';
+import { throttle, debounce } from 'throttle-debounce';
+
+import Editor from './PageEditor/Editor';
+import Preview from './PageEditor/Preview';
+
+export default class PageEditor extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    const config = this.props.crowi.getConfig();
+    const isUploadable = config.upload.image || config.upload.file;
+    const isUploadableFile = config.upload.file;
+
+    this.state = {
+      revisionId: this.props.revisionId,
+      markdown: this.props.markdown,
+      isUploadable,
+      isUploadableFile,
+      editorTheme: this.props.editorTheme,
+    };
+
+    this.setCaretLine = this.setCaretLine.bind(this);
+    this.focusToEditor = this.focusToEditor.bind(this);
+    this.onMarkdownChanged = this.onMarkdownChanged.bind(this);
+    this.onSave = this.onSave.bind(this);
+    this.onUpload = this.onUpload.bind(this);
+    this.onEditorScroll = this.onEditorScroll.bind(this);
+    this.getMaxScrollTop = this.getMaxScrollTop.bind(this);
+    this.getScrollTop = this.getScrollTop.bind(this);
+    this.saveDraft = this.saveDraft.bind(this);
+    this.clearDraft = this.clearDraft.bind(this);
+    this.pageSavedHandler = this.pageSavedHandler.bind(this);
+    this.apiErrorHandler = this.apiErrorHandler.bind(this);
+
+    // create throttled function
+    this.renderWithDebounce = debounce(50, throttle(100, this.renderPreview));
+    this.saveDraftWithDebounce = debounce(300, this.saveDraft);
+  }
+
+  componentWillMount() {
+    // restore draft
+    this.restoreDraft();
+    // initial rendering
+    this.renderPreview(this.state.markdown);
+  }
+
+  focusToEditor() {
+    this.refs.editor.forceToFocus();
+  }
+
+  /**
+   * set caret position of editor
+   * @param {number} line
+   */
+  setCaretLine(line) {
+    this.refs.editor.setCaretLine(line);
+  }
+
+  /**
+   * set theme (used from the outside)
+   * @param {string} theme theme name
+   */
+  setEditorTheme(theme) {
+    this.setState({ editorTheme: theme });
+  }
+
+  /**
+   * the change event handler for `markdown` state
+   * @param {string} value
+   */
+  onMarkdownChanged(value) {
+    this.renderWithDebounce(value);
+    this.saveDraftWithDebounce()
+  }
+
+  /**
+   * the save event handler
+   */
+  onSave() {
+    let endpoint;
+    let data;
+
+    // update
+    if (this.props.pageId != null) {
+      endpoint = '/pages.update';
+      data = {
+        page_id: this.props.pageId,
+        revision_id: this.state.revisionId,
+        body: this.state.markdown,
+      };
+    }
+    // create
+    else {
+      endpoint = '/pages.create';
+      data = {
+        path: this.props.pagePath,
+        body: this.state.markdown,
+      };
+    }
+
+    this.props.crowi.apiPost(endpoint, data)
+      .then((res) => {
+        // show toastr
+        toastr.success(undefined, 'Saved successfully', {
+          closeButton: true,
+          progressBar: true,
+          newestOnTop: false,
+          showDuration: "100",
+          hideDuration: "100",
+          timeOut: "1200",
+          extendedTimeOut: "150",
+        });
+
+        this.pageSavedHandler(res.page);
+      })
+      .catch(this.apiErrorHandler)
+  }
+
+  /**
+   * the upload event handler
+   * @param {any} files
+   */
+  onUpload(file) {
+    const endpoint = '/attachments.add';
+
+    // create a FromData instance
+    const formData = new FormData();
+    formData.append('_csrf', this.props.crowi.csrfToken);
+    formData.append('file', file);
+    formData.append('path', this.props.pagePath);
+    formData.append('page_id', this.props.pageId || 0);
+
+    // post
+    this.props.crowi.apiPost(endpoint, formData)
+      .then((res) => {
+        const url = res.url;
+        const attachment = res.attachment;
+        const fileName = attachment.originalName;
+
+        let insertText = `[${fileName}](${url})`;
+        // when image
+        if (attachment.fileFormat.startsWith('image/')) {
+          // modify to "![fileName](url)" syntax
+          insertText = '!' + insertText;
+        }
+        this.refs.editor.insertText(insertText);
+
+        // update page information if created
+        if (res.pageCreated) {
+          this.pageSavedHandler(res.page);
+        }
+      })
+      .catch(this.apiErrorHandler)
+      // finally
+      .then(() => {
+        this.refs.editor.terminateUploadingState();
+      });
+  }
+
+  /**
+   * the scroll event handler from codemirror
+   * @param {any} data {left, top, width, height, clientWidth, clientHeight} object that represents the current scroll position, the size of the scrollable area, and the size of the visible area (minus scrollbars).
+   *    see https://codemirror.net/doc/manual.html#events
+   */
+  onEditorScroll(data) {
+    const rate = data.top / (data.height - data.clientHeight)
+    const top = this.getScrollTop(this.previewElement, rate);
+
+    this.previewElement.scrollTop = top;
+  }
+  /**
+   * transplanted from crowi-form.js -- 2018.01.21 Yuki Takei
+   * @param {*} dom
+   */
+  getMaxScrollTop(dom) {
+    var rect = dom.getBoundingClientRect();
+    return dom.scrollHeight - rect.height;
+  };
+  /**
+   * transplanted from crowi-form.js -- 2018.01.21 Yuki Takei
+   * @param {*} dom
+   */
+  getScrollTop(dom, rate) {
+    var maxScrollTop = this.getMaxScrollTop(dom);
+    var top = maxScrollTop * rate;
+    return top;
+  };
+
+  /*
+   * methods for draft
+   */
+  restoreDraft() {
+    // restore draft when the first time to edit
+    const draft = this.props.crowi.findDraft(this.props.pagePath);
+    if (!this.props.revisionId && draft != null) {
+      this.setState({markdown: draft});
+    }
+  }
+  saveDraft() {
+    // only when the first time to edit
+    if (!this.state.revisionId) {
+      this.props.crowi.saveDraft(this.props.pagePath, this.state.markdown);
+    }
+  }
+  clearDraft() {
+    this.props.crowi.clearDraft(this.props.pagePath);
+  }
+
+  pageSavedHandler(page) {
+    // update states
+    this.setState({
+      revisionId: page.revision._id,
+      markdown: page.revision.body
+    })
+
+    // clear draft
+    this.clearDraft();
+
+    // dispatch onSaveSuccess event
+    if (this.props.onSaveSuccess != null) {
+      this.props.onSaveSuccess(page);
+    }
+  }
+
+  apiErrorHandler(error) {
+    console.error(error);
+    toastr.error(error.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: "100",
+      hideDuration: "100",
+      timeOut: "3000",
+    });
+  }
+
+  renderPreview(value) {
+    const config = this.props.crowi.config;
+
+    this.setState({ markdown: value });
+
+    // generate options obj
+    const rendererOptions = {
+      // see: https://www.npmjs.com/package/marked
+      marked: {
+        breaks: config.isEnabledLineBreaks,
+      }
+    };
+
+    // render html
+    var context = {
+      markdown: this.state.markdown,
+      dom: this.previewElement,
+      currentPagePath: decodeURIComponent(location.pathname)
+    };
+
+    this.props.crowi.interceptorManager.process('preRenderPreview', context)
+      .then(() => crowi.interceptorManager.process('prePreProcess', context))
+      .then(() => {
+        context.markdown = crowiRenderer.preProcess(context.markdown, context.dom);
+      })
+      .then(() => crowi.interceptorManager.process('postPreProcess', context))
+      .then(() => {
+        var parsedHTML = crowiRenderer.render(context.markdown, context.dom, rendererOptions);
+        context['parsedHTML'] = parsedHTML;
+      })
+      .then(() => crowi.interceptorManager.process('postRenderPreview', context))
+      .then(() => crowi.interceptorManager.process('preRenderPreviewHtml', context))
+      .then(() => {
+        this.setState({ html: context.parsedHTML });
+
+        // set html to the hidden input (for submitting to save)
+        $('#form-body').val(this.state.markdown);
+      })
+      // process interceptors for post rendering
+      .then(() => crowi.interceptorManager.process('postRenderPreviewHtml', context));
+
+  }
+
+  render() {
+    return (
+      <div className="row">
+        <div className="col-md-6 col-sm-12 page-editor-editor-container">
+          <Editor ref="editor" value={this.state.markdown}
+              isUploadable={this.state.isUploadable}
+              isUploadableFile={this.state.isUploadableFile}
+              theme={this.state.editorTheme}
+              onScroll={this.onEditorScroll}
+              onChange={this.onMarkdownChanged}
+              onSave={this.onSave}
+              onUpload={this.onUpload}
+          />
+        </div>
+        <div className="col-md-6 hidden-sm hidden-xs page-editor-preview-container">
+          <Preview html={this.state.html} inputRef={el => this.previewElement = el} />
+        </div>
+      </div>
+    )
+  }
+}
+
+PageEditor.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  markdown: PropTypes.string.isRequired,
+  pageId: PropTypes.string,
+  revisionId: PropTypes.string,
+  pagePath: PropTypes.string,
+  onSaveSuccess: PropTypes.func,
+  editorTheme: PropTypes.string,
+};

+ 357 - 0
resource/js/components/PageEditor/Editor.js

@@ -0,0 +1,357 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import * as codemirror from 'codemirror';
+
+import { UnControlled as ReactCodeMirror } from 'react-codemirror2';
+require('codemirror/lib/codemirror.css');
+require('codemirror/addon/display/autorefresh');
+require('codemirror/addon/edit/matchbrackets');
+require('codemirror/addon/edit/matchtags');
+require('codemirror/addon/edit/closetag');
+require('codemirror/addon/edit/continuelist');
+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/scroll/annotatescrollbar');
+require('codemirror/addon/fold/foldcode');
+require('codemirror/addon/fold/foldgutter');
+require('codemirror/addon/fold/foldgutter.css');
+require('codemirror/addon/fold/markdown-fold');
+require('codemirror/addon/fold/brace-fold');
+require('codemirror/mode/gfm/gfm');
+
+require('codemirror/theme/elegant.css');
+require('codemirror/theme/neo.css');
+require('codemirror/theme/mdn-like.css');
+require('codemirror/theme/material.css');
+require('codemirror/theme/monokai.css');
+require('codemirror/theme/twilight.css');
+
+
+import Dropzone from 'react-dropzone';
+
+import pasteHelper from './PasteHelper';
+import emojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
+
+
+export default class Editor extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    // https://regex101.com/r/7BN2fR/2
+    this.indentAndMarkPattern = /^([ \t]*)(?:>|\-|\+|\*|\d+\.) /;
+
+    this.state = {
+      value: this.props.value,
+      dropzoneActive: false,
+      isUploading: false,
+    };
+
+    this.getCodeMirror = this.getCodeMirror.bind(this);
+    this.setCaretLine = this.setCaretLine.bind(this);
+    this.forceToFocus = this.forceToFocus.bind(this);
+    this.dispatchSave = this.dispatchSave.bind(this);
+
+    this.onPaste = this.onPaste.bind(this);
+
+    this.onDragEnterForCM = this.onDragEnterForCM.bind(this);
+    this.onDragLeave = this.onDragLeave.bind(this);
+    this.onDrop = this.onDrop.bind(this);
+
+    this.getDropzoneAccept = this.getDropzoneAccept.bind(this);
+    this.getDropzoneClassName = this.getDropzoneClassName.bind(this);
+    this.renderOverlay = this.renderOverlay.bind(this);
+  }
+
+  componentDidMount() {
+    // initialize caret line
+    this.setCaretLine(0);
+    // set save handler
+    codemirror.commands.save = this.dispatchSave;
+  }
+
+  getCodeMirror() {
+    return this.refs.cm.editor;
+  }
+
+  forceToFocus() {
+    const editor = this.getCodeMirror();
+    // use setInterval with reluctance -- 2018.01.11 Yuki Takei
+    const intervalId = setInterval(() => {
+      this.getCodeMirror().focus();
+      if (editor.hasFocus()) {
+        clearInterval(intervalId);
+      }
+    }, 100);
+  }
+
+  /**
+   * set caret position of codemirror
+   * @param {string} number
+   */
+  setCaretLine(line) {
+    const editor = this.getCodeMirror();
+    editor.setCursor({line: line-1});   // leave 'ch' field as null/undefined to indicate the end of line
+  }
+
+  /**
+   * remove overlay and set isUploading to false
+   */
+  terminateUploadingState() {
+    this.setState({
+      dropzoneActive: false,
+      isUploading: false,
+    });
+  }
+
+  /**
+   * insert text
+   * @param {string} text
+   */
+  insertText(text) {
+    const editor = this.getCodeMirror();
+    editor.getDoc().replaceSelection(text);
+  }
+
+  /**
+   * dispatch onSave event
+   */
+  dispatchSave() {
+    if (this.props.onSave != null) {
+      this.props.onSave();
+    }
+  }
+
+  /**
+   * dispatch onUpload event
+   */
+  dispatchUpload(files) {
+    if (this.props.onUpload != null) {
+      this.props.onUpload(files);
+    }
+  }
+
+  /**
+   * CodeMirror paste event handler
+   * see: https://codemirror.net/doc/manual.html#events
+   * @param {any} editor An editor instance of CodeMirror
+   * @param {any} event
+   */
+  onPaste(editor, event) {
+    const types = event.clipboardData.types;
+
+    // text
+    if (types.includes('text/plain')) {
+      pasteHelper.pasteText(editor, event);
+    }
+    // files
+    else if (types.includes('Files')) {
+      const dropzone = this.refs.dropzone;
+      const items = event.clipboardData.items || event.clipboardData.files || [];
+
+      // abort if length is not 1
+      if (items.length != 1) {
+        return;
+      }
+
+      const file = items[0].getAsFile();
+      // check type and size
+      if (pasteHelper.fileAccepted(file, dropzone.props.accept) &&
+          pasteHelper.fileMatchSize(file, dropzone.props.maxSize, dropzone.props.minSize)) {
+
+        this.dispatchUpload(file);
+        this.setState({ isUploading: true });
+      }
+    }
+  }
+
+  onDragEnterForCM(editor, event) {
+    const dataTransfer = event.dataTransfer;
+
+    // do nothing if contents is not files
+    if (!dataTransfer.types.includes('Files')) {
+      return;
+    }
+
+    this.setState({ dropzoneActive: true });
+  }
+
+  onDragLeave() {
+    this.setState({ dropzoneActive: false });
+  }
+
+  onDrop(accepted, rejected) {
+    // rejected
+    if (accepted.length != 1) { // length should be 0 or 1 because `multiple={false}` is set
+      this.setState({ dropzoneActive: false });
+      return;
+    }
+
+    const file = accepted[0];
+    this.dispatchUpload(file);
+    this.setState({ isUploading: true });
+  }
+
+  getDropzoneAccept() {
+    let accept = 'null';    // reject all
+    if (this.props.isUploadable) {
+      if (!this.props.isUploadableFile) {
+        accept = 'image/*'  // image only
+      }
+      else {
+        accept = '';        // allow all
+      }
+    }
+
+    return accept;
+  }
+
+  getDropzoneClassName() {
+    let className = 'dropzone';
+    if (!this.props.isUploadable) {
+      className += ' dropzone-unuploadable';
+    }
+    else {
+      className += ' dropzone-uploadable';
+
+      if (this.props.isUploadableFile) {
+        className += ' dropzone-uploadablefile';
+      }
+    }
+
+    // uploading
+    if (this.state.isUploading) {
+      className += ' dropzone-uploading';
+    }
+
+    return className;
+  }
+
+  renderOverlay() {
+    const overlayStyle = {
+      position: 'absolute',
+      zIndex: 1060, // FIXME: required because .content-main.on-edit has 'z-index:1050'
+      top: 0,
+      right: 0,
+      bottom: 0,
+      left: 0,
+    };
+
+    return (
+      <div style={overlayStyle} className="dropzone-overlay">
+        {this.state.isUploading &&
+          <span className="dropzone-overlay-content">
+            <i className="fa fa-spinner fa-pulse fa-fw"></i>
+            <span className="sr-only">Uploading...</span>
+          </span>
+        }
+        {!this.state.isUploading && <span className="dropzone-overlay-content"></span>}
+      </div>
+    );
+  }
+
+  render() {
+    const flexContainer = {
+      height: '100%',
+      display: 'flex',
+      flexDirection: 'column',
+    }
+    const expandHeight = {
+      height: 'calc(100% - 20px)'
+    }
+
+    const theme = this.props.theme || 'elegant';
+    return (
+      <div style={flexContainer}>
+        <Dropzone
+          ref="dropzone"
+          disableClick
+          disablePreview={true}
+          style={expandHeight}
+          accept={this.getDropzoneAccept()}
+          className={this.getDropzoneClassName()}
+          acceptClassName="dropzone-accepted"
+          rejectClassName="dropzone-rejected"
+          multiple={false}
+          onDragLeave={this.onDragLeave}
+          onDrop={this.onDrop}
+        >
+          { this.state.dropzoneActive && this.renderOverlay() }
+
+          <ReactCodeMirror
+            ref="cm"
+            editorDidMount={(editor) => {
+              // add event handlers
+              editor.on('paste', this.onPaste);
+            }}
+            value={this.state.value}
+            options={{
+              mode: 'gfm',
+              theme: theme,
+              lineNumbers: true,
+              tabSize: 4,
+              indentUnit: 4,
+              lineWrapping: true,
+              autoRefresh: true,
+              autoCloseTags: true,
+              matchBrackets: true,
+              matchTags: {bothTags: true},
+              // folding
+              foldGutter: true,
+              gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"],
+              // match-highlighter, matchesonscrollbar, annotatescrollbar options
+              highlightSelectionMatches: {annotateScrollbar: true},
+              // markdown mode options
+              highlightFormatting: true,
+              // continuelist, indentlist
+              extraKeys: {
+                "Enter": "newlineAndIndentContinueMarkdownList",
+                "Tab": "indentMore",
+                "Shift-Tab": "indentLess",
+                "Ctrl-Q": (cm) => { cm.foldCode(cm.getCursor()) },
+              }
+            }}
+            onScroll={(editor, data) => {
+              if (this.props.onScroll != null) {
+                this.props.onScroll(data);
+              }
+            }}
+            onChange={(editor, data, value) => {
+              if (this.props.onChange != null) {
+                this.props.onChange(value);
+              }
+
+              // Emoji AutoComplete
+              emojiAutoCompleteHelper.showHint(editor);
+            }}
+            onDragEnter={this.onDragEnterForCM}
+          />
+        </Dropzone>
+
+        <button type="button" className="btn btn-default btn-block btn-open-dropzone"
+            onClick={() => {this.refs.dropzone.open()}}>
+
+          <i className="fa fa-paperclip" aria-hidden="true"></i>&nbsp;
+          Attach files by dragging &amp; dropping,&nbsp;
+          <span className="btn-link">selecting them</span>,&nbsp;
+          or pasting from the clipboard.
+        </button>
+      </div>
+    )
+  }
+
+}
+
+Editor.propTypes = {
+  value: PropTypes.string,
+  theme: PropTypes.string,
+  isUploadable: PropTypes.bool,
+  isUploadableFile: PropTypes.bool,
+  onChange: PropTypes.func,
+  onScroll: PropTypes.func,
+  onSave: PropTypes.func,
+  onUpload: PropTypes.func,
+};

+ 147 - 0
resource/js/components/PageEditor/EmojiAutoCompleteHelper.js

@@ -0,0 +1,147 @@
+import axios from 'axios';
+
+class EmojiAutoCompleteHelper {
+
+  constructor() {
+    this.emojiStrategy = {};
+    this.emojiShortnameImageMap = {}
+
+    this.initEmojiImageMap()
+      .then(() => {
+        Object.freeze(this);  // freeze after initializing data
+      })
+
+    this.initEmojiImageMap = this.initEmojiImageMap.bind(this);
+    this.showHint = this.showHint.bind(this);
+  }
+
+  initEmojiImageMap() {
+    const emojiStrategyUrl = 'https://cdn.jsdelivr.net/npm/emojione@3.1.2/emoji_strategy.json';
+
+    return axios.get(emojiStrategyUrl)
+      .then((res) => {
+        this.emojiStrategy = res.data;
+        for (let unicode in this.emojiStrategy) {
+          const data = this.emojiStrategy[unicode];
+          const shortname = data.shortname;
+          // add image tag
+          this.emojiShortnameImageMap[shortname] = emojione.shortnameToImage(shortname);
+        }
+      });
+  }
+
+  /**
+   * try to find emoji terms and show hint
+   * @param {any} editor An editor instance of CodeMirror
+   */
+  showHint(editor) {
+    // see https://regex101.com/r/gy3i03/1
+    const pattern = /:[^:\s]+/
+
+    const currentPos = editor.getCursor();
+    // find previous ':shortname'
+    const sc = editor.getSearchCursor(pattern, currentPos, { multiline: false });
+    if (sc.findPrevious()) {
+      const isInputtingEmoji = (currentPos.line === sc.to().line && currentPos.ch === sc.to().ch);
+      // return if it isn't inputting emoji
+      if (!isInputtingEmoji) {
+        return;
+      }
+    }
+    else {
+      return;
+    }
+
+    // see https://codemirror.net/doc/manual.html#addon_show-hint
+    editor.showHint({
+      completeSingle: false,
+      // closeOnUnfocus: false,  // for debug
+      hint: () => {
+        const matched = editor.getDoc().getRange(sc.from(), sc.to());
+        const term = matched.replace(':', '');  // remove ':' in the head
+
+        // get a list of shortnames
+        const shortnames = this.searchEmojiShortnames(term);
+        if (shortnames.length >= 1) {
+          return {
+            list: this.generateEmojiRenderer(shortnames),
+            from: sc.from(),
+            to: sc.to(),
+          };
+        }
+      },
+    });
+  }
+
+  /**
+   * see https://codemirror.net/doc/manual.html#addon_show-hint
+   * @param {string[]} emojiShortnames a list of shortname
+   */
+  generateEmojiRenderer(emojiShortnames) {
+    return emojiShortnames.map((shortname) => {
+      return {
+        text: shortname,
+        className: 'crowi-emoji-autocomplete',
+        render: (element) => {
+          element.innerHTML =
+            `<div class="img-container">${this.emojiShortnameImageMap[shortname]}</div>` +
+            `<span class="shortname-container">${shortname}</span>`;
+        }
+      }
+    });
+  }
+
+  /**
+   * transplanted from https://github.com/emojione/emojione/blob/master/examples/OTHER.md
+   * @param {string} term
+   * @returns {string[]} a list of shortname
+   */
+  searchEmojiShortnames(term) {
+    const maxLength = 12;
+
+    let results1 = [], results2 = [], results3 = [], results4 = [];
+    const countLen1 = () => { results1.length; }
+    const countLen2 = () => { countLen1() + results2.length; }
+    const countLen3 = () => { countLen2() + results3.length; }
+    const countLen4 = () => { countLen3() + results4.length; }
+    // TODO performance tune
+    // when total length of all results is less than `maxLength`
+    for (let unicode in this.emojiStrategy) {
+      const data = this.emojiStrategy[unicode];
+
+      if (maxLength <= countLen1()) { break; }
+      // prefix match to shortname
+      else if (data.shortname.indexOf(`:${term}`) > -1) {
+        results1.push(data.shortname);
+        continue;
+      }
+      else if (maxLength <= countLen2()) { continue; }
+      // partial match to shortname
+      else if (data.shortname.indexOf(term) > -1) {
+        results2.push(data.shortname);
+        continue;
+      }
+      else if (maxLength <= countLen3()) { continue; }
+      // partial match to elements of aliases
+      else if ((data.aliases != null) && data.aliases.find(elem => elem.indexOf(term) > -1)) {
+        results3.push(data.shortname);
+        continue;
+      }
+      else if (maxLength <= countLen4()) { continue; }
+      // partial match to elements of keywords
+      else if ((data.keywords != null) && data.keywords.find(elem => elem.indexOf(term) > -1)) {
+        results4.push(data.shortname);
+      }
+    };
+
+    let results = results1.concat(results2).concat(results3).concat(results4);
+    results = results.slice(0, maxLength);
+
+    return results;
+  }
+
+}
+
+// singleton pattern
+const instance = new EmojiAutoCompleteHelper();
+export default instance;

+ 140 - 0
resource/js/components/PageEditor/PasteHelper.js

@@ -0,0 +1,140 @@
+import accepts from 'attr-accept'
+
+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);
+  }
+
+  /**
+   * paste text
+   * @param {any} editor An editor instance of CodeMirror
+   * @param {any} event
+   */
+  pasteText(editor, event) {
+    // 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);
+      }
+    }
+  }
+
+  /**
+   * 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;
+  }
+
+
+  // Firefox versions prior to 53 return a bogus MIME type for every file drag, so dragovers with
+  /**
+   * transplanted from react-dropzone
+   * @see https://github.com/react-dropzone/react-dropzone/blob/master/src/utils/index.js
+   *
+   * @param {*} file
+   * @param {*} accept
+   */
+  fileAccepted(file, accept) {
+    return file.type === 'application/x-moz-file' || accepts(file, accept)
+  }
+  /**
+   * transplanted from react-dropzone
+   * @see https://github.com/react-dropzone/react-dropzone/blob/master/src/utils/index.js
+   *
+   * @param {*} file
+   * @param {number} maxSize
+   * @param {number} minSize
+   */
+  fileMatchSize(file, maxSize, minSize) {
+    return file.size <= maxSize && file.size >= minSize
+  }
+}
+
+// singleton pattern
+const instance = new PasteHelper();
+Object.freeze(instance);
+export default instance;

+ 27 - 0
resource/js/components/PageEditor/Preview.js

@@ -0,0 +1,27 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class Preview extends React.Component {
+
+  constructor(props) {
+    super(props);
+  }
+
+  generateInnerHtml(html) {
+    return {__html: html};
+  }
+
+  render() {
+    return (
+      <div
+        ref={this.props.inputRef}
+        className="wiki page-editor-preview-body" dangerouslySetInnerHTML={this.generateInnerHtml(this.props.html)}>
+      </div>
+    )
+  }
+}
+
+Preview.propTypes = {
+  html: PropTypes.string,
+  inputRef: PropTypes.func.isRequired,  // for getting div element
+};

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

@@ -0,0 +1,57 @@
+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,
 }
 

+ 3 - 1
resource/js/components/SearchPage/DeletePageListModal.js

@@ -1,7 +1,9 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { Button, Modal, Checkbox } from 'react-bootstrap';
+import { Button } from 'react-bootstrap/es/Button';
+import { Modal } from 'react-bootstrap/es/Modal';
+import { Checkbox } from 'react-bootstrap/es/Checkbox';
 
 import ReactUtils from '../ReactUtils';
 

+ 9 - 55
resource/js/legacy/crowi-form.js

@@ -1,19 +1,8 @@
   var pageId = $('#content-main').data('page-id');
   var pagePath= $('#content-main').data('path');
-  var isEnabledLineBreaks = $('#content-main').data('linebreaks-enabled');
-
-  // generate options obj
-  var rendererOptions = {
-    // see: https://www.npmjs.com/package/marked
-    marked: {
-      breaks: isEnabledLineBreaks
-    }
-  };
 
   require('bootstrap-sass');
-  require('inline-attachment/src/inline-attachment');
   require('./thirdparty-js/jquery.selection');
-  const toastr = require('toastr');
 
   // show/hide
   function FetchPagesUpdatePostAndInsert(path) {
@@ -56,49 +45,15 @@
     $('.content-main').removeClass('on-edit');
   });
 
-  // detect mutations for #edit-form
-  const targetSelector = '#edit-form';
-  var mo = new MutationObserver(function(mutations){
-    // initialize caret position when '#edit-form' activated
-    if (mutations[0].target.classList.contains('active')) {
-      initCaretPosition();
-    }
-  });
-  mo.observe(
-    document.querySelector(targetSelector),
-    {
-      attributes: true,
-      attributeFilter: ['class'],
-    }
-  );
-
-  /**
-   * scroll the textarea named '#form-body' according to the attribute 'data-caret-position'
-   *  that is set in Crowi.setScrollPositionToFormBody
-   */
-  function initCaretPosition() {
-    const textarea = document.querySelector('#form-body');
-    const position = textarea.getAttribute('data-caret-position');
-
-    if (position !== null) {
-      // focus
-      textarea.blur();
-      textarea.focus();
-      // scroll to the bottom for a moment
-      textarea.scrollTop = textarea.scrollHeight;
-      // set caret to the target position
-      textarea.selectionStart = position;
-      textarea.selectionEnd = position;
-
-      // remove attribute
-      textarea.removeAttribute('data-caret-position');
-    }
-  }
-
 /**
  * DOM ready
  */
 $(function() {
+  /*
+   * DUPRECATED CODES
+   * using PageEditor React Component -- 2017.01.06 Yuki Takei
+   *
+
   // preview watch
   var originalContent = $('#form-body').val();
 
@@ -177,12 +132,13 @@ $(function() {
       crowi.clearDraft(pagePath);
     }
   });
+  */
   $('#page-form').on('submit', function(e) {
     // avoid message
-    isFormChanged = false;
+    // isFormChanged = false;
     crowi.clearDraft(pagePath);
   });
-
+  /*
   // This is a temporary implementation until porting to React.
   var insertText = function(start, end, newText, mode) {
     var editor = document.querySelector('#form-body');
@@ -411,9 +367,6 @@ $(function() {
     }
   };
 
-  /**
-   * event handler when 'Ctrl-S' pressed
-   */
   var handleSKey = function(event) {
     if (!event.ctrlKey && !event.metaKey) {
       return;
@@ -667,4 +620,5 @@ $(function() {
     });
   };
   enableScrollSync();
+  */
 });

+ 106 - 35
resource/js/legacy/crowi.js

@@ -3,6 +3,8 @@
 */
 
 var io = require('socket.io-client');
+var entities = require("entities");
+var getLineFromPos = require('get-line-from-pos');
 require('bootstrap-sass');
 require('jquery.cookie');
 
@@ -45,11 +47,12 @@ Crowi.appendEditSectionButtons = function(contentId, markdown) {
     if (position < 0) { // if not found, search with header text only
       position = markdown.search(text);
     }
+    const line = getLineFromPos(markdown, position);
 
     // add button
     $(this).append(`
       <span class="revision-head-edit-button">
-        <a href="#edit-form" onClick="Crowi.setCaretPositionToFormBody(${position})">
+        <a href="#edit-form" onClick="Crowi.setCaretLineData(${line})">
           <i class="fa fa-edit"></i>
         </a>
       </span>
@@ -59,14 +62,39 @@ Crowi.appendEditSectionButtons = function(contentId, markdown) {
 };
 
 /**
- * set 'data-caret-position' attribute that will be processed in crowi-form.js
- * @param {number} position
+ * set 'data-caret-line' attribute that will be processed when 'shown.bs.tab' event fired
+ * @param {number} line
  */
-Crowi.setCaretPositionToFormBody = function(position) {
-  const formBody = document.querySelector('#form-body');
-  formBody.setAttribute('data-caret-position', position);
+Crowi.setCaretLineData = function(line) {
+  const pageEditorDom = document.querySelector('#page-editor');
+  pageEditorDom.setAttribute('data-caret-line', line);
 }
 
+/**
+ * invoked when;
+ *
+ * 1. window loaded
+ * 2. 'shown.bs.tab' event fired
+ */
+Crowi.setCaretLineAndFocusToEditor = function() {
+  // get 'data-caret-line' attributes
+  const pageEditorDom = document.querySelector('#page-editor');
+
+  if (pageEditorDom == null) {
+    return;
+  }
+
+  const line = pageEditorDom.getAttribute('data-caret-line');
+
+  if (line != null) {
+    crowi.setCaretLine(line);
+    // reset data-caret-line attribute
+    pageEditorDom.removeAttribute('data-caret-line');
+  }
+
+  // focus
+  crowi.focusToEditor();
+}
 
 Crowi.revisionToc = function(contentId, tocId) {
   var $content = $(contentId || '#revision-body-content');
@@ -114,27 +142,6 @@ Crowi.revisionToc = function(contentId, tocId) {
   });
 };
 
-
-Crowi.escape = function(s) {
-  s = s.replace(/&/g, '&amp;')
-    .replace(/</g, '&lt;')
-    .replace(/>/g, '&gt;')
-    .replace(/'/g, '&#39;')
-    .replace(/"/g, '&quot;')
-    ;
-  return s;
-};
-Crowi.unescape = function(s) {
-  s = s.replace(/&nbsp;/g, ' ')
-    .replace(/&amp;/g, '&')
-    .replace(/&lt;/g, '<')
-    .replace(/&gt;/g, '>')
-    .replace(/&#39;/g, '\'')
-    .replace(/&quot;/g, '"')
-    ;
-  return s;
-};
-
 // original: middleware.swigFilter
 Crowi.userPicture = function (user) {
   if (!user) {
@@ -177,6 +184,24 @@ Crowi.modifyScrollTop = function() {
   }, timeout);
 }
 
+Crowi.updateCurrentRevision = function(revisionId) {
+  $('#page-form [name="pageForm[currentRevision]"]').val(revisionId);
+}
+
+Crowi.handleKeyEHandler = (event) => {
+  // show editor
+  $('a[data-toggle="tab"][href="#edit-form"]').tab('show');
+}
+
+Crowi.handleKeyCHandler = (event) => {
+  // show modal to create a page
+  $('#create-page').modal();
+}
+
+Crowi.handleKeyCtrlSlashHandler = (event) => {
+  // show modal to create a page
+  $('#shortcuts-modal').modal('toggle');
+}
 
 $(function() {
   var config = JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}');
@@ -187,14 +212,13 @@ $(function() {
   var currentUser = $('#content-main').data('current-user');
   var isSeen = $('#content-main').data('page-is-seen');
   var pagePath= $('#content-main').data('path');
-  var isEnabledLineBreaks = $('#content-main').data('linebreaks-enabled');
   var isSavedStatesOfTabChanges = config['isSavedStatesOfTabChanges'];
 
   // generate options obj
   var rendererOptions = {
     // see: https://www.npmjs.com/package/marked
     marked: {
-      breaks: isEnabledLineBreaks
+      breaks: config.isEnabledLineBreaks
     }
   };
 
@@ -428,18 +452,25 @@ $(function() {
     return false;
   });
 
-  // list-link
+  /*
+   * wrap short path with <strong></strong>
+   */
   $('.page-list-link').each(function() {
     var $link = $(this);
     var text = $link.text();
     var path = $link.data('path');
-    var shortPath = new String($link.data('short-path'));
+    var shortPath = String($link.data('short-path')); // convert to string
+
+    if (path == null || shortPath == null) {
+      // continue
+      return;
+    }
 
     var escape = function(s) {
       return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
     };
-    path = Crowi.escape(path);
-    var pattern = escape(Crowi.escape(shortPath)) + '(/)?$';
+    path = entities.encodeHTML(path);
+    var pattern = escape(entities.encodeHTML(shortPath)) + '(/)?$';
 
     $link.html(path.replace(new RegExp(pattern), '<strong>' + shortPath + '$1</strong>'));
   });
@@ -456,7 +487,7 @@ $(function() {
         var $revisionBody = $(revisionBody);
         var revisionPath = '#' + id + ' .revision-path';
 
-        var markdown = Crowi.unescape($(contentId).html());
+        var markdown = entities.decodeHTML($(contentId).html());
         var parsedHTML = crowiRenderer.render(markdown, $revisionBody.get(0), rendererOptions);
         $revisionBody.html(parsedHTML);
 
@@ -502,7 +533,7 @@ $(function() {
     // if page exists
     var $rawTextOriginal = $('#raw-text-original');
     if ($rawTextOriginal.length > 0) {
-      var markdown = Crowi.unescape($('#raw-text-original').html());
+      var markdown = entities.decodeHTML($('#raw-text-original').html());
       var dom = $('#revision-body-content').get(0);
 
       // create context object
@@ -823,6 +854,11 @@ $(function() {
       window.location.replace('#edit-form');
     });
   }
+
+  // focus to editor when 'shown.bs.tab' event fired
+  $('a[href="#edit-form"]').on('shown.bs.tab', function(e) {
+    Crowi.setCaretLineAndFocusToEditor();
+  });
 });
 
 Crowi.getRevisionBodyContent = function() {
@@ -906,6 +942,8 @@ window.addEventListener('load', function(e) {
 
   Crowi.highlightSelectedSection(location.hash);
   Crowi.modifyScrollTop();
+  Crowi.setCaretLineAndFocusToEditor();
+  Crowi.setCaretLineAndFocusToEditor();
 });
 
 window.addEventListener('hashchange', function(e) {
@@ -926,3 +964,36 @@ window.addEventListener('hashchange', function(e) {
     $('a[data-toggle="tab"][href="#revision-body"]').tab('show');
   }
 });
+
+window.addEventListener('keypress', (event) => {
+  const target = event.target;
+
+  // ignore when target dom is input
+  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;
+  }
+
+  switch (event.key) {
+    case 'e':
+      if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
+        Crowi.handleKeyEHandler(event);
+      }
+      break;
+    case 'c':
+      if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
+        Crowi.handleKeyCHandler(event);
+      }
+      break;
+    case '/':
+      if (event.ctrlKey || event.metaKey) {
+        Crowi.handleKeyCtrlSlashHandler(event);
+      }
+      break;
+  }
+});

+ 27 - 23
resource/js/util/Crowi.js

@@ -19,6 +19,7 @@ export default class Crowi {
     this.location = window.location || {};
     this.document = window.document || {};
     this.localStorage = window.localStorage || {};
+    this.pageEditor = undefined;
 
     this.fetchUsers = this.fetchUsers.bind(this);
     this.apiGet = this.apiGet.bind(this);
@@ -61,6 +62,10 @@ export default class Crowi {
     return this.config;
   }
 
+  setPageEditor(pageEditor) {
+    this.pageEditor = pageEditor;
+  }
+
   recoverData() {
     const keys = [
       'userByName',
@@ -112,14 +117,26 @@ export default class Crowi {
     });
   }
 
+  setCaretLine(line) {
+    if (this.pageEditor != null) {
+      this.pageEditor.setCaretLine(line);
+    }
+  }
+
+  focusToEditor() {
+    if (this.pageEditor != null) {
+      this.pageEditor.focusToEditor();
+    }
+  }
+
   clearDraft(path) {
     delete this.draft[path];
-    this.localStorage.draft = JSON.stringify(this.draft);
+    this.localStorage.setItem('draft', JSON.stringify(this.draft));
   }
 
   saveDraft(path, body) {
     this.draft[path] = body;
-    this.localStorage.draft = JSON.stringify(this.draft);
+    this.localStorage.setItem('draft', JSON.stringify(this.draft));
   }
 
   findDraft(path) {
@@ -130,6 +147,14 @@ export default class Crowi {
     return null;
   }
 
+  saveEditorTheme(theme) {
+    this.localStorage.setItem('editorTheme', theme);
+  }
+
+  loadEditorTheme() {
+    return this.localStorage.getItem('editorTheme');
+  }
+
   findUserById(userId) {
     if (this.userById && this.userById[userId]) {
       return this.userById[userId];
@@ -187,26 +212,5 @@ export default class Crowi {
     });
   }
 
-  static escape (html, encode) {
-    return html
-      .replace(!encode ? /&(?!#?\w+;)/g : /&/g, '&amp;')
-      .replace(/</g, '&lt;')
-      .replace(/>/g, '&gt;')
-      .replace(/"/g, '&quot;')
-      .replace(/'/g, '&#39;');
-  }
-
-  static unescape(html) {
-    return html.replace(/&([#\w]+);/g, function(_, n) {
-      n = n.toLowerCase();
-      if (n === 'colon') return ':';
-      if (n.charAt(0) === '#') {
-        return n.charAt(1) === 'x'
-          ? String.fromCharCode(parseInt(n.substring(2), 16))
-          : String.fromCharCode(+n.substring(1));
-      }
-      return '';
-    });
-  }
 }
 

+ 3 - 2
resource/js/util/CrowiRenderer.js

@@ -1,5 +1,6 @@
 import marked from 'marked';
 import hljs from 'highlight.js';
+import * as entities from 'entities';
 
 import MarkdownFixer from './PreProcessor/MarkdownFixer';
 import Linker        from './PreProcessor/Linker';
@@ -81,7 +82,7 @@ export default class CrowiRenderer {
         result = code;
       }
 
-      result = (escape ? result : Crowi.escape(result, true));
+      result = (escape ? result : entities.encodeHTML(result));
 
       let citeTag = '';
       if (langFn) {
@@ -91,7 +92,7 @@ export default class CrowiRenderer {
     }
 
     // no lang specified
-    return `<pre class="wiki-code"><code>${Crowi.escape(code, true)}\n</code></pre>`;
+    return `<pre class="wiki-code"><code>${entities.encodeHTML(code)}\n</code></pre>`;
 
   }
 

+ 2 - 1
resource/js/util/LangProcessor/PlantUML.js

@@ -1,5 +1,6 @@
 import plantuml from 'plantuml-encoder';
 import crypto from 'crypto';
+import * as entities from 'entities';
 
 export default class PlantUML {
 
@@ -17,7 +18,7 @@ export default class PlantUML {
   process(code, lang) {
     const config = crowi.getConfig();
     if (!config.env.PLANTUML_URI) {
-      return `<pre class="wiki-code"><code>${Crowi.escape(code, true)}\n</code></pre>`;
+      return `<pre class="wiki-code"><code>${entities.encodeHTML(code)}\n</code></pre>`;
     }
 
     let plantumlUri = config.env.PLANTUML_URI;

+ 3 - 2
resource/js/util/LangProcessor/Tsv2Table.js

@@ -1,3 +1,4 @@
+import * as entities from 'entities';
 
 export default class Tsv2Table {
 
@@ -32,7 +33,7 @@ export default class Tsv2Table {
 
     //console.log('head', headLine);
     headers = this.splitColums(headLine).map(col => {
-      return `<th>${Crowi.escape(col)}</th>`;
+      return `<th>${entities.encodeHTML(col)}</th>`;
     });
 
     if (headers.length < option.cols) {
@@ -53,7 +54,7 @@ export default class Tsv2Table {
 
     rows = codeLines.map(row => {
       const cols = this.splitColums(row).map(col => {
-        return `<td>${Crowi.escape(col)}</td>`;
+        return `<td>${entities.encodeHTML(col)}</td>`;
       }).join('');
       return `<tr>${cols}</tr>`;
     });

+ 0 - 2
resource/js/util/PostProcessor/Emoji.js

@@ -1,5 +1,3 @@
-import emojione from 'emojione';
-
 export default class Emoji {
 
   process(markdown) {

+ 5 - 5
test/models/page.test.js

@@ -14,7 +14,7 @@ describe('Page', () => {
     createdUsers;
 
   before(done => {
-    Promise.resolve().then(() => {
+    conn.collection('pages').remove().then(() => {
       var userFixture = [
         {name: 'Anon 0', username: 'anonymous0', email: 'anonymous0@example.com'},
         {name: 'Anon 1', username: 'anonymous1', email: 'anonymous1@example.com'}
@@ -27,7 +27,7 @@ describe('Page', () => {
 
       var fixture = [
         {
-          path: '/user/anonymous/memo',
+          path: '/user/anonymous0/memo',
           grant: Page.GRANT_RESTRICTED,
           grantedUsers: [testUser0],
           creator: testUser0
@@ -174,7 +174,7 @@ describe('Page', () => {
         User.findOne({email: 'anonymous0@example.com'}, (err, user) => {
           if (err) { done(err); }
 
-          Page.findOne({path: '/user/anonymous/memo'}, (err, page) => {
+          Page.findOne({path: '/user/anonymous0/memo'}, (err, page) => {
             expect(page.isCreator(user)).to.be.equal(true);
             done();
           })
@@ -187,7 +187,7 @@ describe('Page', () => {
         User.findOne({email: 'anonymous1@example.com'}, (err, user) => {
           if (err) { done(err); }
 
-          Page.findOne({path: '/user/anonymous/memo'}, (err, page) => {
+          Page.findOne({path: '/user/anonymous0/memo'}, (err, page) => {
             expect(page.isCreator(user)).to.be.equal(false);
             done();
           })
@@ -202,7 +202,7 @@ describe('Page', () => {
         User.findOne({email: 'anonymous0@example.com'}, (err, user) => {
           if (err) { done(err); }
 
-          Page.findOne({path: '/user/anonymous/memo'}, (err, page) => {
+          Page.findOne({path: '/user/anonymous0/memo'}, (err, page) => {
             if (err) { done(err); }
 
             expect(page.isGrantedFor(user)).to.be.equal(true);

+ 8 - 0
test/models/user.test.js

@@ -11,6 +11,14 @@ describe('User', function () {
     User   = utils.models.User,
     conn   = utils.mongoose.connection;
 
+  // clear collection
+  before(done => {
+    conn.collection('users').remove()
+      .then(() => {
+        done();
+      });
+  });
+
   describe('Create and Find.', function () {
     context('The user', function() {
       it('should created', function(done) {

+ 1 - 1
test/utils.js

@@ -14,7 +14,7 @@ before('Create database connection and clean up', function (done) {
     return done();
   }
 
-  mongoose.connect(mongoUri, { useMongoClient: true });
+  mongoose.connect(mongoUri);
 
   function clearDB() {
     for (var i in mongoose.connection.collections) {

+ 113 - 111
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"
 
@@ -326,6 +326,10 @@ asynckit@^0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
 
+attr-accept@^1.0.3:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-1.1.0.tgz#b5cd35227f163935a8f1de10ed3eba16941f6be6"
+
 autoprefixer@^6.3.1:
   version "6.7.7"
   resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-6.7.7.tgz#1dbd1c835658e35ce3f9984099db00585c782014"
@@ -365,7 +369,7 @@ aws4@^1.2.1, aws4@^1.6.0:
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e"
 
-axios@^0.17.0:
+axios@^0.17.0, axios@^0.17.1:
   version "0.17.1"
   resolved "https://registry.yarnpkg.com/axios/-/axios-0.17.1.tgz#2d8e3e5d0bdbd7327f91bc814f5c57660f81824d"
   dependencies:
@@ -1138,10 +1142,6 @@ buffer-equal-constant-time@1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
 
-buffer-shims@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/buffer-shims/-/buffer-shims-1.0.0.tgz#9978ce317388c649ad8793028c3477ef044a8b51"
-
 buffer-xor@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9"
@@ -1408,6 +1408,10 @@ code-point-at@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
 
+codemirror@^5.33.0:
+  version "5.33.0"
+  resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.33.0.tgz#462ad9a6fe8d38b541a9536a3997e1ef93b40c6a"
+
 color-convert@^1.3.0, color-convert@^1.9.0:
   version "1.9.1"
   resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.1.tgz#c1261107aeb2f294ebffec9ed9ecad529a6097ed"
@@ -1997,10 +2001,6 @@ elliptic@^6.0.0:
     minimalistic-assert "^1.0.0"
     minimalistic-crypto-utils "^1.0.0"
 
-emojione@^3.1.2:
-  version "3.1.2"
-  resolved "https://registry.yarnpkg.com/emojione/-/emojione-3.1.2.tgz#991e30c80db4b1cf15eacb257620a7edce9c6ef4"
-
 emojis-list@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389"
@@ -2069,6 +2069,10 @@ enhanced-resolve@^3.4.0:
     object-assign "^4.0.1"
     tapable "^0.2.7"
 
+entities@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0"
+
 env-cmd@^7.0.0:
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/env-cmd/-/env-cmd-7.0.0.tgz#d1fcfea6e0cbe6bf50b7130221d568907b6349bd"
@@ -2113,10 +2117,6 @@ es6-map@^0.1.3:
     es6-symbol "~3.1.1"
     event-emitter "~0.3.5"
 
-es6-promise@3.2.1:
-  version "3.2.1"
-  resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.2.1.tgz#ec56233868032909207170c39448e24449dd1fc4"
-
 es6-set@~0.1.5:
   version "0.1.5"
   resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.5.tgz#d2b3ec5d4d800ced818db538d28974db0a73ccb1"
@@ -2575,6 +2575,10 @@ get-func-name@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41"
 
+get-line-from-pos@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/get-line-from-pos/-/get-line-from-pos-1.0.0.tgz#e3ca483035eef374ad40fff4d43df3fa2da328b3"
+
 get-stdin@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
@@ -2641,29 +2645,32 @@ good-listener@^1.2.2:
   dependencies:
     delegate "^3.1.2"
 
-google-auth-library@0.12.0:
-  version "0.12.0"
-  resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-0.12.0.tgz#a3fc6c296d00bb54e4d877ef581a05947330d07f"
+google-auth-library@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-1.1.0.tgz#f3e17e8d9f93a0cdd8c78503427cb656be3aa435"
   dependencies:
-    gtoken "^1.2.3"
+    axios "^0.17.1"
+    gtoken "^2.0.2"
     jws "^3.1.4"
     lodash.isstring "^4.0.1"
-    lodash.merge "^4.6.0"
-    request "^2.81.0"
+    lru-cache "^4.1.1"
 
-google-p12-pem@^0.1.0:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/google-p12-pem/-/google-p12-pem-0.1.2.tgz#33c46ab021aa734fa0332b3960a9a3ffcb2f3177"
+google-p12-pem@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/google-p12-pem/-/google-p12-pem-1.0.0.tgz#375cc4e977a311908d365b47ed3519e7207c1f77"
   dependencies:
     node-forge "^0.7.1"
+    pify "^3.0.0"
 
-googleapis@^23.0.0:
-  version "23.0.0"
-  resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-23.0.0.tgz#02cbe8c56d346e2e8fb0ef8aec52e722322c6fe7"
+googleapis@^25.0.0:
+  version "25.0.0"
+  resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-25.0.0.tgz#0f6f48109584e035e266022eb7fdc1a86823da3a"
   dependencies:
     async "2.6.0"
-    google-auth-library "0.12.0"
+    google-auth-library "^1.0.0"
+    qs "^6.5.1"
     string-template "1.0.0"
+    uuid "^3.1.0"
 
 graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6:
   version "4.1.11"
@@ -2677,14 +2684,15 @@ growly@^1.2.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081"
 
-gtoken@^1.2.3:
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-1.2.3.tgz#5509571b8afd4322e124cf66cf68115284c476d8"
+gtoken@^2.0.2:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-2.1.0.tgz#e65028d32d1d52eeb17b00f85ef0f7484f0fd36f"
   dependencies:
-    google-p12-pem "^0.1.0"
+    axios "^0.17.0"
+    google-p12-pem "^1.0.0"
     jws "^3.0.0"
-    mime "^1.4.1"
-    request "^2.72.0"
+    mime "^2.0.3"
+    pify "^3.0.0"
 
 gzip-size@^3.0.0:
   version "3.0.0"
@@ -2837,10 +2845,6 @@ home-or-tmp@^2.0.0:
     os-homedir "^1.0.0"
     os-tmpdir "^1.0.1"
 
-hooks-fixed@2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/hooks-fixed/-/hooks-fixed-2.0.2.tgz#20076daa07e77d8a6106883ce3f1722e051140b0"
-
 hosted-git-info@^2.1.4:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.5.0.tgz#6d60e34b3abbc8313062c3b798ef8d901a07af3c"
@@ -2962,10 +2966,6 @@ ini@~1.3.0:
   version "1.3.5"
   resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
 
-inline-attachment@~2.0.3:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/inline-attachment/-/inline-attachment-2.0.3.tgz#5ee32374583fabd3b7206df2e20f251ba20c4306"
-
 interpret@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.1.0.tgz#7ed1b1410c6a0e0f78cf95d3b8440c63f78b8614"
@@ -3162,6 +3162,10 @@ jmespath@0.15.0:
   version "0.15.0"
   resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217"
 
+jquery-ui@^1.12.1:
+  version "1.12.1"
+  resolved "https://registry.yarnpkg.com/jquery-ui/-/jquery-ui-1.12.1.tgz#bcb4045c8dd0539c134bc1488cdd3e768a7a9e51"
+
 jquery.cookie@~1.4.1:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/jquery.cookie/-/jquery.cookie-1.4.1.tgz#d63dce209eab691fe63316db08ca9e47e0f9385b"
@@ -3278,9 +3282,9 @@ jws@^3.0.0, jws@^3.1.4:
     jwa "^1.1.4"
     safe-buffer "^5.0.1"
 
-kareem@1.5.0:
-  version "1.5.0"
-  resolved "https://registry.yarnpkg.com/kareem/-/kareem-1.5.0.tgz#e3e4101d9dcfde299769daf4b4db64d895d17448"
+kareem@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/kareem/-/kareem-2.0.1.tgz#f17f77e9032f64aa402b334f91fb4407fe4c042c"
 
 keycode@^2.1.2:
   version "2.1.9"
@@ -3562,10 +3566,6 @@ lodash.merge@^3.3.2:
     lodash.keysin "^3.0.0"
     lodash.toplainobject "^3.0.0"
 
-lodash.merge@^4.6.0:
-  version "4.6.0"
-  resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.0.tgz#69884ba144ac33fe699737a6086deffadd0f89c5"
-
 lodash.mergewith@^4.6.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.0.tgz#150cf0a16791f5903b8891eab154609274bdea55"
@@ -3630,7 +3630,7 @@ loud-rejection@^1.0.0:
     currently-unhandled "^0.4.1"
     signal-exit "^3.0.0"
 
-lru-cache@^4.0.1, lru-cache@^4.0.2:
+lru-cache@^4.0.1, lru-cache@^4.0.2, lru-cache@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.1.tgz#622e32e82488b49279114a4f9ecf45e7cd6bba55"
   dependencies:
@@ -3782,9 +3782,9 @@ mime@1.4.1:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6"
 
-mime@^1.4.1:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
+mime@^2.0.3:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-2.2.0.tgz#161e541965551d3b549fa1114391e3a3d55b923b"
 
 mimic-fn@^1.0.0:
   version "1.1.0"
@@ -3833,9 +3833,9 @@ mkdirp@0.5.1, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkd
   dependencies:
     minimist "0.0.8"
 
-mocha@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/mocha/-/mocha-4.1.0.tgz#7d86cfbcf35cb829e2754c32e17355ec05338794"
+mocha@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/mocha/-/mocha-5.0.0.tgz#cccac988b0bc5477119cba0e43de7af6d6ad8f4e"
   dependencies:
     browser-stdout "1.3.0"
     commander "2.11.0"
@@ -3852,49 +3852,49 @@ moment@^2.10.6:
   version "2.20.1"
   resolved "https://registry.yarnpkg.com/moment/-/moment-2.20.1.tgz#d6eb1a46cbcc14a2b2f9434112c1ff8907f313fd"
 
-mongodb-core@2.1.18:
-  version "2.1.18"
-  resolved "https://registry.yarnpkg.com/mongodb-core/-/mongodb-core-2.1.18.tgz#4c46139bdf3a1f032ded91db49f38eec01659050"
+mongodb-core@3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/mongodb-core/-/mongodb-core-3.0.1.tgz#ff6dc36ee96ff596953d80a6840d6731bc92efed"
   dependencies:
     bson "~1.0.4"
-    require_optional "~1.0.0"
+    require_optional "^1.0.1"
 
-mongodb@2.2.34:
-  version "2.2.34"
-  resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-2.2.34.tgz#a34f59bbeb61754aec432de72c3fe21526a44c1a"
+mongodb@3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-3.0.1.tgz#278ee8006257ec22798594a6259546825d6de1b2"
   dependencies:
-    es6-promise "3.2.1"
-    mongodb-core "2.1.18"
-    readable-stream "2.2.7"
+    mongodb-core "3.0.1"
 
-mongoose-paginate@5.0.x:
+mongoose-legacy-pluralize@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/mongoose-legacy-pluralize/-/mongoose-legacy-pluralize-1.0.1.tgz#31ae25db45c30f1448c0f93f52769e903367c701"
+
+mongoose-paginate@^5.0.0:
   version "5.0.3"
   resolved "https://registry.yarnpkg.com/mongoose-paginate/-/mongoose-paginate-5.0.3.tgz#d7ae49ed5bf64f1f7af7620ea865b67058c55371"
   dependencies:
     bluebird "3.0.5"
 
-mongoose-unique-validator@^1.0.6:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/mongoose-unique-validator/-/mongoose-unique-validator-1.0.6.tgz#fab31e68c1a5ba6f5b05da8e93842db55eb0a3b1"
+mongoose-unique-validator@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/mongoose-unique-validator/-/mongoose-unique-validator-2.0.0.tgz#76e406cd0b322bbbb79496e2b7425d885ce661c4"
   dependencies:
     lodash.foreach "^4.1.0"
     lodash.get "^4.0.2"
 
-mongoose@^4.13.5:
-  version "4.13.9"
-  resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-4.13.9.tgz#ca4d99aed6e36e87854c2295387e7ea17966cfe3"
+mongoose@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-5.0.0.tgz#85e92fd72d718b21b3d55a62b09434da37354b5a"
   dependencies:
     async "2.1.4"
     bson "~1.0.4"
-    hooks-fixed "2.0.2"
-    kareem "1.5.0"
+    kareem "2.0.1"
     lodash.get "4.4.2"
-    mongodb "2.2.34"
+    mongodb "3.0.1"
+    mongoose-legacy-pluralize "1.0.1"
     mpath "0.3.0"
-    mpromise "0.5.5"
-    mquery "2.3.3"
+    mquery "3.0.0-rc0"
     ms "2.0.0"
-    muri "1.3.0"
     regexp-clone "0.0.1"
     sliced "1.0.1"
 
@@ -3912,13 +3912,9 @@ mpath@0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/mpath/-/mpath-0.3.0.tgz#7a58f789e9b5fd3c94520634157960f26bd5ef44"
 
-mpromise@0.5.5:
-  version "0.5.5"
-  resolved "https://registry.yarnpkg.com/mpromise/-/mpromise-0.5.5.tgz#f5b24259d763acc2257b0a0c8c6d866fd51732e6"
-
-mquery@2.3.3:
-  version "2.3.3"
-  resolved "https://registry.yarnpkg.com/mquery/-/mquery-2.3.3.tgz#221412e5d4e7290ca5582dd16ea8f190a506b518"
+mquery@3.0.0-rc0:
+  version "3.0.0-rc0"
+  resolved "https://registry.yarnpkg.com/mquery/-/mquery-3.0.0-rc0.tgz#05ec656e92f079828bedf4202e60fb8eaacb9f47"
   dependencies:
     bluebird "3.5.0"
     debug "2.6.9"
@@ -3942,10 +3938,6 @@ multer@~1.3.0:
     type-is "^1.6.4"
     xtend "^4.0.0"
 
-muri@1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/muri/-/muri-1.3.0.tgz#aeccf3db64c56aa7c5b34e00f95b7878527a4721"
-
 mv@~2:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/mv/-/mv-2.1.1.tgz#ae6ce0d6f6d5e0a4f7d893798d03c1ea9559b6a2"
@@ -4859,7 +4851,7 @@ prop-types-extra@^1.0.1:
   dependencies:
     warning "^3.0.0"
 
-prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.0:
+prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.7, prop-types@^15.5.8, prop-types@^15.6.0:
   version "15.6.0"
   resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856"
   dependencies:
@@ -4918,7 +4910,7 @@ q@^1.1.2:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
 
-qs@6.5.1, qs@~6.5.1:
+qs@6.5.1, qs@^6.5.1, qs@~6.5.1:
   version "6.5.1"
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8"
 
@@ -5036,6 +5028,10 @@ 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-dom@^16.0.0:
   version "16.2.0"
   resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.2.0.tgz#69003178601c0ca19b709b33a83369fe6124c044"
@@ -5045,6 +5041,13 @@ react-dom@^16.0.0:
     object-assign "^4.1.1"
     prop-types "^15.6.0"
 
+react-dropzone@^4.2.7:
+  version "4.2.7"
+  resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-4.2.7.tgz#a4963b1f725d5a91e63cd1c2b55ddce537953d46"
+  dependencies:
+    attr-accept "^1.0.3"
+    prop-types "^15.5.7"
+
 react-input-autosize@^2.0.1:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.1.tgz#ec428fa15b1592994fb5f9aa15bb1eb6baf420f8"
@@ -5131,18 +5134,6 @@ readable-stream@1.1.x:
     isarray "0.0.1"
     string_decoder "~0.10.x"
 
-readable-stream@2.2.7:
-  version "2.2.7"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.2.7.tgz#07057acbe2467b22042d36f98c5ad507054e95b1"
-  dependencies:
-    buffer-shims "~1.0.0"
-    core-util-is "~1.0.0"
-    inherits "~2.0.1"
-    isarray "~1.0.0"
-    process-nextick-args "~1.0.6"
-    string_decoder "~1.0.0"
-    util-deprecate "~1.0.1"
-
 readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.2.6, readable-stream@^2.3.3:
   version "2.3.3"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.3.tgz#368f2512d79f9d46fdfc71349ae7878bbc1eb95c"
@@ -5277,7 +5268,7 @@ repeating@^2.0.0:
   dependencies:
     is-finite "^1.0.0"
 
-request@2, request@^2.72.0, request@^2.81.0:
+request@2:
   version "2.83.0"
   resolved "https://registry.yarnpkg.com/request/-/request-2.83.0.tgz#ca0b65da02ed62935887808e6f510381034e3356"
   dependencies:
@@ -5389,7 +5380,7 @@ require-main-filename@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1"
 
-require_optional@~1.0.0:
+require_optional@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/require_optional/-/require_optional-1.0.1.tgz#4cf35a4247f64ca3df8c2ef208cc494b1ca8fc2e"
   dependencies:
@@ -5492,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"
@@ -5811,7 +5809,7 @@ string-width@^2.0.0:
     is-fullwidth-code-point "^2.0.0"
     strip-ansi "^4.0.0"
 
-string_decoder@^1.0.0, string_decoder@~1.0.0, string_decoder@~1.0.3:
+string_decoder@^1.0.0, string_decoder@~1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.0.3.tgz#0fc67d7c141825de94282dd536bec6b9bce860ab"
   dependencies:
@@ -5861,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"
@@ -5944,6 +5942,10 @@ text-encoding@^0.6.4:
   version "0.6.4"
   resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.6.4.tgz#e399a982257a276dae428bb92845cb71bdc26d19"
 
+throttle-debounce@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-1.0.1.tgz#dad0fe130f9daf3719fdea33dc36a8e6ba7f30b5"
+
 through2@^2.0.1, through2@^2.0.2, through2@^2.0.3:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.3.tgz#0004569b37c7c74ba39c43f3ced78d1ad94140be"