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

Merge pull request #241 from weseek/feat/page-editor-with-react

Feat/page editor with react
Yuki Takei 8 лет назад
Родитель
Сommit
c526687c1e
44 измененных файлов с 1750 добавлено и 523 удалено
  1. 1 1
      lib/form/admin/app.js
  2. 12 12
      lib/locales/en-US/translation.json
  3. 13 15
      lib/locales/ja/translation.json
  4. 4 1
      lib/models/config.js
  5. 43 46
      lib/views/_form.html
  6. 4 49
      lib/views/admin/customize.html
  7. 1 8
      lib/views/crowi-plus/base/not_found_nosidebar.html
  8. 1 8
      lib/views/crowi-plus/base/page_list_nosidebar.html
  9. 1 8
      lib/views/crowi-plus/base/page_nosidebar.html
  10. 1 8
      lib/views/crowi-plus/base/user_page_nosidebar.html
  11. 8 0
      lib/views/crowi-plus/widget/system-version.html
  12. 1 1
      lib/views/layout/2column.html
  13. 1 1
      lib/views/layout/layout.html
  14. 1 1
      lib/views/layout/single.html
  15. 0 113
      lib/views/modal/help.html
  16. 73 0
      lib/views/modal/shortcuts.html
  17. 0 5
      lib/views/not_found.html
  18. 0 1
      lib/views/page.html
  19. 0 1
      lib/views/page_list.html
  20. 7 1
      package.json
  21. 29 29
      resource/css/_admin.scss
  22. 176 65
      resource/css/_form.scss
  23. 11 11
      resource/css/_layout_crowi-plus.scss
  24. 1 1
      resource/css/_page.scss
  25. 77 0
      resource/css/_shortcuts.scss
  26. 3 4
      resource/css/_wiki.scss
  27. 2 1
      resource/css/crowi.scss
  28. 43 4
      resource/js/app.js
  29. 61 0
      resource/js/components/Admin/CustomCssEditor.js
  30. 61 0
      resource/js/components/Admin/CustomScriptEditor.js
  31. 2 2
      resource/js/components/Page/RevisionUrl.js
  32. 4 4
      resource/js/components/PageAttachment/DeleteAttachmentModal.js
  33. 303 0
      resource/js/components/PageEditor.js
  34. 346 0
      resource/js/components/PageEditor/Editor.js
  35. 138 0
      resource/js/components/PageEditor/EmojiAutoCompleteHelper.js
  36. 140 0
      resource/js/components/PageEditor/PasteHelper.js
  37. 27 0
      resource/js/components/PageEditor/Preview.js
  38. 9 55
      resource/js/legacy/crowi-form.js
  39. 85 33
      resource/js/legacy/crowi.js
  40. 15 23
      resource/js/util/Crowi.js
  41. 3 2
      resource/js/util/CrowiRenderer.js
  42. 2 1
      resource/js/util/LangProcessor/PlantUML.js
  43. 3 2
      resource/js/util/LangProcessor/Tsv2Table.js
  44. 37 6
      yarn.lock

+ 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()
 );
 

+ 12 - 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,18 @@
     }
   },
 
-  "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",
+          "Delete Line": "Delete Line"
       }
   }
 }

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

@@ -12,6 +12,7 @@
   "Create": "作成",
   "Admin": "管理",
   "New": "作成",
+  "Shortcuts": "ショートカット",
 
   "Update": "更新",
   "Update Page": "ページを更新",
@@ -187,19 +188,16 @@
     }
   },
 
-  "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": "左インデント",
+        "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,

+ 43 - 46
lib/views/_form.html

@@ -13,56 +13,53 @@
   </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"
-          >
+  <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>#}
+
+    <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>
-        {% endif %}
+        <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>
+      {% 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>
-  </form>
-  <div class="col-md-6 hidden-sm hidden-xs">
-    <div id="preview-body" class="wiki preview-body">
-    </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 - 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 %}

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

@@ -0,0 +1,8 @@
+<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> Ctrl+/</a>
+  </span>
+</div>

+ 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> Ctrl+/</a>
       </p>
     </footer>
   </div>

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

@@ -176,7 +176,7 @@
 {% block body_end %}
 {% endblock %}
 
-{% include '../modal/help.html' %}
+{% include '../modal/shortcuts.html' %}
 </body>
 {% endblock %}
 

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

@@ -23,7 +23,7 @@
     <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> Ctrl+/</a>
   </span>
 </div>
 {% 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 -->

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

@@ -0,0 +1,73 @@
+<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.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>
+    var platform = navigator.platform.toLowerCase();
+    var isMac = (platform.indexOf('mac') > -1);
+
+    document.querySelectorAll('.cmd-key').forEach((element) => {
+      console.log(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">

+ 0 - 1
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 %}

+ 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 %}">

+ 7 - 1
package.json

@@ -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",
@@ -73,6 +74,7 @@
     "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,6 +83,7 @@
     "express-session": "~1.15.0",
     "express-webpack-assets": "^0.1.0",
     "file-loader": "^1.1.0",
+    "get-line-from-pos": "^1.0.0",
     "googleapis": "^24.0.0",
     "graceful-fs": "^4.1.11",
     "highlight.js": "^9.10.0",
@@ -88,7 +91,7 @@
     "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",
@@ -112,7 +115,9 @@
     "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",
@@ -121,6 +126,7 @@
     "socket.io-client": "^2.0.3",
     "style-loader": "^0.19.0",
     "swig-templates": "^2.0.2",
+    "throttle-debounce": "^1.0.1",
     "toastr": "^2.1.2",
     "uglifycss": "^0.0.27",
     "webpack": "^3.1.0",

+ 29 - 29
resource/css/_admin.scss

@@ -1,7 +1,7 @@
 // import crowi variable
 @import 'utilities';
 
-.crowi { // {{{
+.crowi {
 
   .admin-user-menu {
     .dropdown-menu {
@@ -15,40 +15,40 @@
       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;
-  }
+    .passport-logo {
+      padding: 4px;
+      height: 32px;
+      background-color: black;
+    }
 
-  .auth-mechanism-configurations {
-    min-height: 800px;
+    .auth-mechanism-configurations {
+      min-height: 800px;
+    }
   }
-} // }}}
+}

+ 176 - 65
resource/css/_form.scss

@@ -24,7 +24,6 @@
   .tab-content {
     top: 48px;
     bottom: 58px;
-    padding: 0 12px;
     position: absolute;
     z-index: 1051;
     left: 0;
@@ -36,71 +35,168 @@
       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;
@@ -128,10 +224,6 @@
   .page-list-container {
     display: none;
   }
-  .portal-form-header,
-  .portal-form {
-    display: block;
-  }
 
   .portal-form-header {
     height: 16px;
@@ -140,18 +232,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 {
@@ -170,3 +250,34 @@ 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;
+
+      img.emojione {
+        width: 1.6em;
+      }
+    }
+  }
+  // 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;
+  }
+}

+ 1 - 1
resource/css/_page.scss

@@ -267,7 +267,7 @@
 
   .footer { // {{{
     position: fixed;
-    width: 100%;
+    width: calc(25% - 18px);
     bottom: 0px;
     height: 26px;
     padding: 4px;

+ 77 - 0
resource/css/_shortcuts.scss

@@ -0,0 +1,77 @@
+// 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;
+
+    &.cmd-key.mac {
+      &:after {
+        content: '⌘';
+      }
+    }
+    &.cmd-key.win {
+      &:after {
+        content: 'ctrl';
+      }
+    }
+
+    &.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;
+    // }
+  }
+
+}

+ 3 - 4
resource/css/_wiki.scss

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

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

+ 43 - 4
resource/js/app.js

@@ -6,6 +6,7 @@ import CrowiRenderer from './util/CrowiRenderer';
 
 import HeaderSearchBox  from './components/HeaderSearchBox';
 import SearchPage       from './components/SearchPage';
+import PageEditor       from './components/PageEditor';
 import PageListSearch   from './components/PageListSearch';
 import PageHistory      from './components/PageHistory';
 import PageComments     from './components/PageComments';
@@ -16,6 +17,12 @@ import RevisionPath     from './components/Page/RevisionPath';
 import RevisionUrl      from './components/Page/RevisionUrl';
 import BookmarkButton   from './components/BookmarkButton';
 
+import CustomCssEditor  from './components/Admin/CustomCssEditor';
+import CustomScriptEditor from './components/Admin/CustomScriptEditor';
+
+import * as entities from 'entities';
+
+
 if (!window) {
   window = {};
 }
@@ -25,11 +32,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) {
@@ -56,6 +63,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
@@ -64,6 +76,8 @@ if (isEnabledPlugins) {
 const componentMappings = {
   'search-top': <HeaderSearchBox crowi={crowi} />,
   'search-page': <SearchPage crowi={crowi} />,
+  'page-editor': <PageEditor crowi={crowi} pageId={pageId} revisionId={pageRevisionId} pagePath={pagePath} markdown={entities.decodeHTML(pageContent)}
+                              onSaveSuccess={onSaveSuccess}/>,
   'page-list-search': <PageListSearch crowi={crowi} />,
   'page-comments-list': <PageComments pageId={pageId} revisionId={pageRevisionId} revisionCreatedAt= {pageRevisionCreatedAt} crowi={crowi} />,
   'page-attachment': <PageAttachment pageId={pageId} pageContent={pageContent} crowi={crowi} />,
@@ -92,7 +106,32 @@ if (elem) {
   ReactDOM.render(<PageCommentFormBehavior crowi={crowi} pageComments={componentInstances['page-comments-list']} />, elem);
 }
 
+// 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,
+};

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

+ 4 - 4
resource/js/components/PageAttachment/DeleteAttachmentModal.js

@@ -18,12 +18,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>
       );
     }
 

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

@@ -0,0 +1,303 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import * as toastr from 'toastr';
+import {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,
+    };
+
+    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 debounced function
+    this.saveDraftWithDebounce = debounce(300, this.saveDraft);
+  }
+
+  componentWillMount() {
+    // restore draft
+    this.restoreDraft();
+    // initial preview
+    this.renderPreview();
+  }
+
+  focusToEditor() {
+    this.refs.editor.forceToFocus();
+  }
+
+  /**
+   * set caret position of editor
+   * @param {number} line
+   */
+  setCaretLine(line) {
+    this.refs.editor.setCaretLine(line);
+  }
+
+  /**
+   * the change event handler for `markdown` state
+   * @param {string} value
+   */
+  onMarkdownChanged(value) {
+    this.setState({
+      markdown: value,
+    });
+
+    this.renderPreview();
+
+    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;
+  };
+
+  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() {
+    const config = this.props.crowi.config;
+
+    // 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}
+              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,
+};

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

@@ -0,0 +1,346 @@
+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/eclipse.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: '100%',
+    }
+
+    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: 'eclipse',
+              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,
+  isUploadable: PropTypes.bool,
+  isUploadableFile: PropTypes.bool,
+  onChange: PropTypes.func,
+  onScroll: PropTypes.func,
+  onSave: PropTypes.func,
+  onUpload: PropTypes.func,
+};

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

@@ -0,0 +1,138 @@
+import emojione from 'emojione';
+import emojiStrategy from 'emojione/emoji_strategy.json';
+
+class EmojiAutoCompleteHelper {
+
+  constructor() {
+    this.initEmojiImageMap = this.initEmojiImageMap.bind(this);
+    this.showHint = this.showHint.bind(this);
+
+    this.initEmojiImageMap()
+  }
+
+  initEmojiImageMap() {
+    this.emojiShortnameImageMap = {};
+    for (let unicode in emojiStrategy) {
+      const data = 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 emojiStrategy) {
+      const data = 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();
+Object.freeze(instance);
+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
+};

+ 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();
+  */
 });

+ 85 - 33
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
     }
   };
 
@@ -438,8 +462,8 @@ $(function() {
     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 +480,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 +526,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 +847,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 +935,8 @@ window.addEventListener('load', function(e) {
 
   Crowi.highlightSelectedSection(location.hash);
   Crowi.modifyScrollTop();
+  Crowi.setCaretLineAndFocusToEditor();
+  Crowi.setCaretLineAndFocusToEditor();
 });
 
 window.addEventListener('hashchange', function(e) {
@@ -926,3 +957,24 @@ window.addEventListener('hashchange', function(e) {
     $('a[data-toggle="tab"][href="#revision-body"]').tab('show');
   }
 });
+
+window.addEventListener('keypress', (event) => {
+  // ignore when target dom is input
+  const inputPattern = /input|textinput|textarea/i;
+  if (event.target.tagName.match(inputPattern)) {
+    return;
+  }
+
+  switch (event.key) {
+    case 'e':
+      Crowi.handleKeyEHandler(event);
+      break;
+    case 'c':
+      Crowi.handleKeyCHandler(event);
+    case '/':
+      if (event.ctrlKey || event.metaKey) {
+        Crowi.handleKeyCtrlSlashHandler(event);
+      }
+    break;
+  }
+});

+ 15 - 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,22 @@ export default class Crowi {
     });
   }
 
+  setCaretLine(line) {
+    this.pageEditor.setCaretLine(line);
+  }
+
+  focusToEditor() {
+    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) {
@@ -187,26 +200,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>`;
     });

+ 37 - 6
yarn.lock

@@ -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"
@@ -1404,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"
@@ -2065,6 +2073,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"
@@ -2567,6 +2579,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"
@@ -2950,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"
@@ -3150,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"
@@ -3857,7 +3873,7 @@ 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.x:
+mongoose-paginate@^5.0.0:
   version "5.0.3"
   resolved "https://registry.yarnpkg.com/mongoose-paginate/-/mongoose-paginate-5.0.3.tgz#d7ae49ed5bf64f1f7af7620ea865b67058c55371"
   dependencies:
@@ -4839,7 +4855,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:
@@ -5016,6 +5032,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"
@@ -5025,6 +5045,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"
@@ -5912,6 +5939,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"