فهرست منبع

Merge branch 'master' into feat/OAuth-Twitter

mizozobu 7 سال پیش
والد
کامیت
67c8478c61
95فایلهای تغییر یافته به همراه5403 افزوده شده و 2312 حذف شده
  1. 2 1
      .gitignore
  2. 19 4
      CHANGES.md
  3. 16 13
      README.md
  4. 1 2
      config/env.dev.js
  5. 7 2
      config/webpack.common.js
  6. 1 3
      lib/crowi/dev.js
  7. 17 0
      lib/crowi/index.js
  8. 19 0
      lib/form/admin/notificationGlobal.js
  9. 1 0
      lib/form/admin/securityPassportLdap.js
  10. 1 0
      lib/form/index.js
  11. 9 0
      lib/locales/en-US/notifications/comment.txt
  12. 5 0
      lib/locales/en-US/notifications/pageCreate.txt
  13. 5 0
      lib/locales/en-US/notifications/pageDelete.txt
  14. 5 0
      lib/locales/en-US/notifications/pageEdit.txt
  15. 5 0
      lib/locales/en-US/notifications/pageLike.txt
  16. 5 0
      lib/locales/en-US/notifications/pageMove.txt
  17. 22 1
      lib/locales/en-US/translation.json
  18. 0 0
      lib/locales/ja/notifications/comment.txt
  19. 0 0
      lib/locales/ja/notifications/pageCreate.txt
  20. 0 0
      lib/locales/ja/notifications/pageDelete.txt
  21. 0 0
      lib/locales/ja/notifications/pageEdit.txt
  22. 0 0
      lib/locales/ja/notifications/pageLike.txt
  23. 0 0
      lib/locales/ja/notifications/pageMove.txt
  24. 22 1
      lib/locales/ja/translation.json
  25. 10 0
      lib/models/GlobalNotificationSetting.js
  26. 16 0
      lib/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js
  27. 16 0
      lib/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js
  28. 113 0
      lib/models/GlobalNotificationSetting/index.js
  29. 1 0
      lib/models/config.js
  30. 4 2
      lib/models/external-account.js
  31. 3 0
      lib/models/index.js
  32. 53 7
      lib/models/page.js
  33. 2 3
      lib/models/revision.js
  34. 10 5
      lib/models/user.js
  35. 129 15
      lib/routes/admin.js
  36. 6 1
      lib/routes/comment.js
  37. 109 18
      lib/routes/hackmd.js
  38. 16 2
      lib/routes/index.js
  39. 6 2
      lib/routes/login-passport.js
  40. 218 231
      lib/routes/page.js
  41. 185 0
      lib/service/global-notification.js
  42. 10 0
      lib/service/passport.js
  43. 3 3
      lib/util/slack.js
  44. 4 0
      lib/util/swigFunctions.js
  45. 11 19
      lib/views/_form.html
  46. 170 0
      lib/views/admin/global-notification-detail.html
  47. 124 0
      lib/views/admin/global-notification.html
  48. 7 7
      lib/views/admin/notification.html
  49. 17 6
      lib/views/admin/widget/passport/ldap.html
  50. 0 3
      lib/views/layout-growi/not_found.html
  51. 1 1
      lib/views/modal/create_page.html
  52. 0 28
      lib/views/modal/page_name_warning.html
  53. 0 5
      lib/views/widget/forbidden_content.html
  54. 1 1
      lib/views/widget/not_found_content.html
  55. 3 4
      lib/views/widget/page_content.html
  56. 0 1
      lib/views/widget/page_modals.html
  57. 5 4
      lib/views/widget/page_tabs.html
  58. 8 6
      package.json
  59. 0 61
      resource/js/agent-for-hackmd.js
  60. 167 89
      resource/js/app.js
  61. 0 69
      resource/js/components/Common/Modal.js
  62. 34 2
      resource/js/components/HeaderSearchBox/SearchForm.js
  63. 20 7
      resource/js/components/NewPageNameInput.js
  64. 17 81
      resource/js/components/PageEditor.js
  65. 231 24
      resource/js/components/PageEditor/CodeMirrorEditor.js
  66. 1 1
      resource/js/components/PageEditor/Editor.js
  67. 159 27
      resource/js/components/PageEditorByHackmd.jsx
  68. 61 12
      resource/js/components/PageEditorByHackmd/HackmdEditor.jsx
  69. 91 0
      resource/js/components/SavePageControls.jsx
  70. 47 62
      resource/js/components/SavePageControls/GrantSelector.jsx
  71. 2 2
      resource/js/components/SearchPage/DeletePageListModal.js
  72. 33 23
      resource/js/components/SearchPage/SearchResult.js
  73. 22 1
      resource/js/components/SearchTypeahead.js
  74. 20 10
      resource/js/components/SlackNotification.jsx
  75. 145 0
      resource/js/hackmd-agent.js
  76. 42 0
      resource/js/hackmd-styles.js
  77. 1 0
      resource/js/i18n.js
  78. 21 9
      resource/js/legacy/crowi-admin.js
  79. 11 605
      resource/js/legacy/crowi-form.js
  80. 2 2
      resource/js/legacy/crowi-presentation.js
  81. 150 314
      resource/js/legacy/crowi.js
  82. 64 0
      resource/js/legacy/thirdparty-js/switchery/switchery.css
  83. 1957 0
      resource/js/legacy/thirdparty-js/switchery/switchery.js
  84. 29 0
      resource/js/util/Crowi.js
  85. 20 4
      resource/styles/agile-admin/inverse/colors/_apply-colors.scss
  86. 6 0
      resource/styles/hackmd/style.scss
  87. 8 0
      resource/styles/scss/_admin.scss
  88. 7 0
      resource/styles/scss/_comment_growi.scss
  89. 1 1
      resource/styles/scss/_create-page.scss
  90. 46 80
      resource/styles/scss/_editor-attachment.scss
  91. 79 0
      resource/styles/scss/_editor-overlay.scss
  92. 82 142
      resource/styles/scss/_on-edit.scss
  93. 7 5
      resource/styles/scss/_search.scss
  94. 5 5
      resource/styles/scss/theme/_override-agileadmin.scss
  95. 392 273
      yarn.lock

+ 2 - 1
.gitignore

@@ -36,5 +36,6 @@ package-lock.json
 # Doc #
 /doc/
 
-# IDE #
+# IDE, dev #
 .idea
+*.orig

+ 19 - 4
CHANGES.md

@@ -3,14 +3,29 @@ CHANGES
 
 ## 3.2.0-RC
 
-* Feature: Simultaneously edit by multiple people with HackMD integration
+* Feature: HackMD integration so that user can simultaneously edit with multiple people
+
+## 3.1.14
+
+* Improvement: Show help for header search box
+* Improvement: Add Markdown Cheatsheet to Editor component
+* Fix: Couldn't delete page completely from search result page
+* Fix: Tabs of trash page are broken
+
+## 3.1.13
+
+* Feature: Global Notification
+* Feature: Send Global Notification with E-mail
+* Improvement: Add attribute mappings for email to LDAP settings
 * Support: Upgrade libs
+    * autoprefixer
+    * css-loader
+    * method-override
+    * optimize-css-assets-webpack-plugin
     * react
+    * react-bootstrap-typeahead
     * react-dom
 
-## 3.1.13-RC
-
-* 
 
 ## 3.1.12
 

+ 16 - 13
README.md

@@ -156,22 +156,25 @@ Environment Variables
     * MONGO_URI: URI to connect to MongoDB.
 * **Option**
     * NODE_ENV: `production` OR `development`.
-    * PORT: Server port. default: `3000`
+    * PORT: Server port. default: `3000`.
     * ELASTICSEARCH_URI: URI to connect to Elasticearch.
     * REDIS_URI: URI to connect to Redis (use it as a session store instead of MongoDB).
-    * PLANTUML_URI: URI to connect to [PlantUML](http://plantuml.com/) server.
-    * BLOCKDIAG_URI: URI to connect to [blockdiag](http://http://blockdiag.com/) server.
     * PASSWORD_SEED: A password seed used by password hash generator.
     * SECRET_TOKEN: A secret key for verifying the integrity of signed cookies.
     * SESSION_NAME: The name of the session ID cookie to set in the response by Express. default: `connect.sid`
     * FILE_UPLOAD: `aws` (default), `local`, `none`
+* **Option to integrate with external systems**
+    * HACKMD_URI: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server.
+    * HACKMD_URI_FOR_SERVER: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server from GROWI Express server. If not set, `HACKMD_URI` will be used.
+    * PLANTUML_URI: URI to connect to [PlantUML](http://plantuml.com/) server.
+    * BLOCKDIAG_URI: URI to connect to [blockdiag](http://http://blockdiag.com/) server.
 * **Option (Overwritable in admin page)**
-    * OAUTH_GOOGLE_CLIENT_ID: Google API client id for OAuth login
-    * OAUTH_GOOGLE_CLIENT_SECRET: Google API client secret for OAuth login
-    * OAUTH_GOOGLE_CALLBACK_URI: Google API callback URI for OAuth login (Set `https://${growi.host}/passport/google/callback`)
-    * OAUTH_GITHUB_CLIENT_ID: GitHub API client id for OAuth login
-    * OAUTH_GITHUB_CLIENT_SECRET: GitHub API client secret for OAuth login
-    * OAUTH_GITHUB_CALLBACK_URI: GitHub API callback URI for OAuth login (Set `https://${growi.host}/passport/github/callback`)
+    * OAUTH_GOOGLE_CLIENT_ID: Google API client id for OAuth login.
+    * OAUTH_GOOGLE_CLIENT_SECRET: Google API client secret for OAuth login.
+    * OAUTH_GOOGLE_CALLBACK_URI: Google API callback URI for OAuth login (Set `https://${growi.host}/passport/google/callback`).
+    * OAUTH_GITHUB_CLIENT_ID: GitHub API client id for OAuth login.
+    * OAUTH_GITHUB_CLIENT_SECRET: GitHub API client secret for OAuth login.
+    * OAUTH_GITHUB_CALLBACK_URI: GitHub API callback URI for OAuth login (Set `https://${growi.host}/passport/github/callback`).
 
 
 Documentation
@@ -191,11 +194,11 @@ For development
 ### Build and Run the app
 
 1. `clone` this repository
-1. `yarn` to install all dependencies
+2. `yarn` to install all dependencies
     * DO NOT USE `npm install`
-1. `npm run build` to build client app
-1. `npm run server` to start the dev server
-1. Access `http://0.0.0.0:3000`
+3. `npm run build` to build client app
+4. `npm run server` to start the dev server
+5. Access `http://0.0.0.0:3000`
 
 Found a Bug?
 -------------

+ 1 - 2
config/env.dev.js

@@ -1,10 +1,9 @@
 module.exports = {
   NODE_ENV: 'development',
   FILE_UPLOAD: 'local',
-  SESSION_NAME: 'connect.growi-dev.sid',
   // MATHJAX: 1,
   ELASTICSEARCH_URI: 'http://localhost:9200/growi',
-  // HACKMD_URI: 'http://localhost:3100',
+  HACKMD_URI: 'http://localhost:3010',
   PLUGIN_NAMES_TOBE_LOADED: [
     // 'growi-plugin-lsx',
     // 'growi-plugin-pukiwiki-like-linker',

+ 7 - 2
config/webpack.common.js

@@ -27,7 +27,8 @@ module.exports = (options) => {
       'js/legacy-presentation':   './resource/js/legacy/crowi-presentation',
       'js/plugin':                './resource/js/plugin',
       'js/ie11-polyfill':         './resource/js/ie11-polyfill',
-      'js/agent-for-hackmd':      './resource/js/agent-for-hackmd',
+      'js/hackmd-agent':          './resource/js/hackmd-agent',
+      'js/hackmd-styles':         './resource/js/hackmd-styles',
       // styles
       'styles/style':                './resource/styles/scss/style.scss',
       'styles/style-presentation':   './resource/styles/scss/style-presentation.scss',
@@ -86,6 +87,10 @@ module.exports = (options) => {
             basenameAsNamespace: true,
           }
         },
+        { // see https://github.com/abpetkov/switchery/issues/120
+          test: /switchery\.js$/,
+          loader: 'imports-loader?module=>false,exports=>false,define=>false,this=>window'
+        },
         {
           test: /\.css$/,
           use: ['style-loader', 'css-loader'],
@@ -151,7 +156,7 @@ module.exports = (options) => {
             test: /node_modules/,
             chunks: (chunk) => {
               // ignore patterns
-              return chunk.name != null && !chunk.name.match(/legacy-presentation|ie11-polyfill|agent-for-hackmd/);
+              return chunk.name != null && !chunk.name.match(/legacy-presentation|ie11-polyfill|hackmd-/);
             },
             name: 'js/vendors',
             // minChunks: 2,

+ 1 - 3
lib/crowi/dev.js

@@ -27,9 +27,7 @@ class CrowiDev {
 
   initPromiseRejectionWarningHandler() {
     // https://qiita.com/syuilo/items/0800d7e44e93203c7285
-    /* eslint-disable no-console */
-    process.on('unhandledRejection', console.dir);
-    /* eslint-enable */
+    process.on('unhandledRejection', console.dir);  // eslint-disable-line no-console
   }
 
   initSwig() {

+ 17 - 0
lib/crowi/index.js

@@ -37,6 +37,7 @@ function Crowi(rootdir, env) {
   this.mailer = {};
   this.interceptorManager = {};
   this.passportService = null;
+  this.globalNotificationService = null;
   this.xss = new Xss();
 
   this.tokens = null;
@@ -90,6 +91,8 @@ Crowi.prototype.init = function() {
       return self.setupSlack();
     }).then(function() {
       return self.setupCsrf();
+    }).then(function() {
+      return self.setUpGlobalNotification();
     });
 };
 
@@ -246,6 +249,10 @@ Crowi.prototype.getInterceptorManager = function() {
   return this.interceptorManager;
 };
 
+Crowi.prototype.getGlobalNotificationService = function() {
+  return this.globalNotificationService;
+};
+
 Crowi.prototype.setupPassport = function() {
   const config = this.getConfig();
   const Config = this.model('Config');
@@ -453,4 +460,14 @@ Crowi.prototype.require = function(modulePath) {
   return require(modulePath);
 };
 
+/**
+ * setup GlobalNotificationService
+ */
+Crowi.prototype.setUpGlobalNotification = function() {
+  const GlobalNotificationService = require('../service/global-notification');
+  if (this.globalNotificationService == null) {
+    this.globalNotificationService = new GlobalNotificationService(this);
+  }
+};
+
 module.exports = Crowi;

+ 19 - 0
lib/form/admin/notificationGlobal.js

@@ -0,0 +1,19 @@
+'use strict';
+
+const form = require('express-form');
+const field = form.field;
+
+module.exports = form(
+  field('notificationGlobal[id]').trim(),
+  field('notificationGlobal[triggerPath]').trim(),
+  field('notificationGlobal[notifyToType]').trim(),
+  field('notificationGlobal[toEmail]').trim(),
+  field('notificationGlobal[slackChannels]').trim(),
+  field('notificationGlobal[triggerEvent:pageCreate]').trim(),
+  field('notificationGlobal[triggerEvent:pageEdit]').trim(),
+  field('notificationGlobal[triggerEvent:pageDelete]').trim(),
+  field('notificationGlobal[triggerEvent:pageMove]').trim(),
+  field('notificationGlobal[triggerEvent:pageLike]').trim(),
+  field('notificationGlobal[triggerEvent:comment]').trim(),
+);
+

+ 1 - 0
lib/form/admin/securityPassportLdap.js

@@ -17,6 +17,7 @@ module.exports = form(
   field('settingForm[security:passport-ldap:searchFilter]'),
   field('settingForm[security:passport-ldap:attrMapUsername]'),
   field('settingForm[security:passport-ldap:attrMapName]'),
+  field('settingForm[security:passport-ldap:attrMapMail]'),
   field('settingForm[security:passport-ldap:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
   field('settingForm[security:passport-ldap:groupSearchBase]'),
   field('settingForm[security:passport-ldap:groupSearchFilter]'),

+ 1 - 0
lib/form/index.js

@@ -37,5 +37,6 @@ module.exports = {
     slackIwhSetting: require('./admin/slackIwhSetting'),
     slackSetting: require('./admin/slackSetting'),
     userGroupCreate: require('./admin/userGroupCreate'),
+    notificationGlobal: require('./admin/notificationGlobal'),
   },
 };

+ 9 - 0
lib/locales/en-US/notifications/comment.txt

@@ -0,0 +1,9 @@
+{{ username }} commented on {{ path }}.
+
+----------------------
+
+{{ comment }}
+
+----------------------
+
+Growi: {{ appTitle }}

+ 5 - 0
lib/locales/en-US/notifications/pageCreate.txt

@@ -0,0 +1,5 @@
+{{ username }} created a new page under {{ path }}.
+
+----------------------
+
+Growi: {{ appTitle }}

+ 5 - 0
lib/locales/en-US/notifications/pageDelete.txt

@@ -0,0 +1,5 @@
+{{ username }} deleted the page  {{ path }}.
+
+----------------------
+
+Growi: {{ appTitle }}

+ 5 - 0
lib/locales/en-US/notifications/pageEdit.txt

@@ -0,0 +1,5 @@
+{{ username }} edited the page {{ path }}.
+
+----------------------
+
+Growi: {{ appTitle }}

+ 5 - 0
lib/locales/en-US/notifications/pageLike.txt

@@ -0,0 +1,5 @@
+{{ username }} liked the page {{ path }}.
+
+----------------------
+
+Growi: {{ appTitle }}

+ 5 - 0
lib/locales/en-US/notifications/pageMove.txt

@@ -0,0 +1,5 @@
+{{ username }} renamed the page {{ oldPath }} to {{ newPath }}.
+
+----------------------
+
+Growi: {{ appTitle }}

+ 22 - 1
lib/locales/en-US/translation.json

@@ -362,7 +362,8 @@
       "search_filter_example1": "Match with 'uid' or 'mail'",
       "search_filter_example2": "Match with 'sAMAccountName' for Active Directory",
       "username_detail": "Specification of mappings for <code>username</code> when creating new users",
-      "name_detail": "Specification of mappings for <code>name</code> when creating new users",
+      "name_detail": "Specification of mappings for full name when creating new users",
+      "mail_detail": "Specification of mappings for mail address when creating new users",
       "group_search_base_DN": "Group Search Base DN",
       "group_search_base_DN_detail": "The base DN from which to search for groups. If defined, also <code>Group Search Filter</code> must be defined for the search to work.",
       "group_search_filter": "Group Search Filter",
@@ -428,6 +429,26 @@
     "import_recommended": "Import recommended %s"
   },
 
+  "notification_setting": {
+    "notification_list": "List of Notification Settings",
+    "add_notification": "Add New",
+    "trigger_path": "Trigger Path",
+    "trigger_path_help": "(expression with %s is supported)",
+    "trigger_events": "Trigger Events",
+    "notify_to": "Notify To",
+    "back_to_list": "Go back to list",
+    "notification_detail": "Notification Setting Details",
+    "event_pageCreate": "When new page is \"CREATED\"",
+    "event_pageEdit": "When page is \"EDITED\"",
+    "event_pageDelete": "When page is \"DELETED\"",
+    "event_pageMove": "When page is \"MOVED\" (renamed)",
+    "event_pageLike": "When someone \"LIKES\" page",
+    "event_comment": "When someone \"COMMENTS\" on page",
+    "email": {
+      "ifttt_link": "Create a new IFTTT applet with Email trigger"
+    }
+  },
+
   "customize_page": {
     "Behavior": "Behavior",
     "Layout": "Layout",

+ 0 - 0
lib/locales/ja/notifications/comment.txt


+ 0 - 0
lib/locales/ja/notifications/pageCreate.txt


+ 0 - 0
lib/locales/ja/notifications/pageDelete.txt


+ 0 - 0
lib/locales/ja/notifications/pageEdit.txt


+ 0 - 0
lib/locales/ja/notifications/pageLike.txt


+ 0 - 0
lib/locales/ja/notifications/pageMove.txt


+ 22 - 1
lib/locales/ja/translation.json

@@ -379,7 +379,8 @@
       "search_filter_example1": "'uid' または 'mail' に一致",
       "search_filter_example2": "'sAMAccountName' に一致 (Active Directory)",
       "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
-      "name_detail": "新規ユーザーの表示名(<code>name</code>)に関連付ける属性",
+      "name_detail": "新規ユーザーの表示名に関連付ける属性",
+      "mail_detail": "新規ユーザーのメールアドレスに関連付ける属性",
       "group_search_base_DN": "グループ検索ベース DN",
       "group_search_base_DN_detail": "グループ検索を実行するベース DN。利用する場合は <code>グループ検索フィルター</code> も入力する必要があります。",
       "group_search_filter": "グループ検索フィルター",
@@ -444,6 +445,26 @@
     "import_recommended": "おすすめをインポート"
   },
 
+  "notification_setting": {
+    "notification_list": "通知設定の一覧",
+    "add_notification": "通知設定の追加",
+    "trigger_path": "トリガーパス",
+    "trigger_path_help": "(%sが使用できます)",
+    "trigger_events": "トリガーイベント",
+    "notify_to": "通知先",
+    "back_to_list": "通知設定一覧に戻る",
+    "notification_detail": "通知詳細設定",
+    "event_pageCreate": "ページが新規作成されたとき",
+    "event_pageEdit": "ページが編集されたとき",
+    "event_pageDelete": "ページが削除されたとき",
+    "event_pageMove": "ページが移動(名前が変更)されたとき",
+    "event_pageLike": "ページに「いいね」がついたとき",
+    "event_comment": "コメントが投稿されたとき",
+    "email": {
+      "ifttt_link": "IFTTT でメールトリガの新しいアプレットを作る"
+    }
+  },
+
   "customize_page": {
     "Behavior": "挙動",
     "Layout": "レイアウト",

+ 10 - 0
lib/models/GlobalNotificationSetting.js

@@ -0,0 +1,10 @@
+const mongoose = require('mongoose');
+const GlobalNotificationSetting = require('./GlobalNotificationSetting/index');
+const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
+const GlobalNotificationSettingSchema = GlobalNotificationSetting.schema;
+
+module.exports = function(crowi) {
+  GlobalNotificationSettingClass.crowi = crowi;
+  GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
+  return mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
+};

+ 16 - 0
lib/models/GlobalNotificationSetting/GlobalNotificationMailSetting.js

@@ -0,0 +1,16 @@
+const mongoose = require('mongoose');
+const GlobalNotificationSetting = require('./index');
+const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
+const GlobalNotificationSettingSchema = GlobalNotificationSetting.schema;
+
+module.exports = function(crowi) {
+  GlobalNotificationSettingClass.crowi = crowi;
+  GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
+
+  const GlobalNotificationSettingModel = mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
+  const GlobalNotificationMailSettingModel = GlobalNotificationSettingModel.discriminator('mail', new mongoose.Schema({
+    toEmail: String,
+  }, {discriminatorKey: 'type'}));
+
+  return GlobalNotificationMailSettingModel;
+};

+ 16 - 0
lib/models/GlobalNotificationSetting/GlobalNotificationSlackSetting.js

@@ -0,0 +1,16 @@
+const mongoose = require('mongoose');
+const GlobalNotificationSetting = require('./index');
+const GlobalNotificationSettingClass = GlobalNotificationSetting.class;
+const GlobalNotificationSettingSchema = GlobalNotificationSetting.schema;
+
+module.exports = function(crowi) {
+  GlobalNotificationSettingClass.crowi = crowi;
+  GlobalNotificationSettingSchema.loadClass(GlobalNotificationSettingClass);
+
+  const GlobalNotificationSettingModel = mongoose.model('GlobalNotificationSetting', GlobalNotificationSettingSchema);
+  const GlobalNotificationSlackSettingModel = GlobalNotificationSettingModel.discriminator('slack', new mongoose.Schema({
+    slackChannels: String,
+  }, {discriminatorKey: 'type'}));
+
+  return GlobalNotificationSlackSettingModel;
+};

+ 113 - 0
lib/models/GlobalNotificationSetting/index.js

@@ -0,0 +1,113 @@
+const mongoose = require('mongoose');
+
+/**
+ * parent schema for GlobalNotificationSetting model
+ */
+const globalNotificationSettingSchema = new mongoose.Schema({
+  isEnabled: { type: Boolean, required: true, default: true },
+  triggerPath: { type: String, required: true },
+  triggerEvents: { type: [String] },
+});
+
+
+/**
+ * GlobalNotificationSetting Class
+ * @class GlobalNotificationSetting
+ */
+class GlobalNotificationSetting {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  /**
+   * enable notification setting
+   * @param {string} id
+   */
+  static async enable(id) {
+    const setting = await this.findOne({_id: id});
+
+    setting.isEnabled = true;
+    setting.save();
+
+    return setting;
+  }
+
+  /**
+   * disable notification setting
+   * @param {string} id
+   */
+  static async disable(id) {
+    const setting = await this.findOne({_id: id});
+
+    setting.isEnabled = false;
+    setting.save();
+
+    return setting;
+  }
+
+  /**
+   * find all notification settings
+   */
+  static async findAll() {
+    const settings = await this.find().sort({ triggerPath: 1 });
+
+    return settings;
+  }
+
+  /**
+   * find a list of notification settings by path and a list of events
+   * @param {string} path
+   * @param {string} event
+   */
+  static async findSettingByPathAndEvent(path, event) {
+    const pathsToMatch = generatePathsToMatch(path);
+
+    const settings = await this.find({
+      triggerPath: {$in: pathsToMatch},
+      triggerEvents: event,
+      isEnabled: true
+    })
+    .sort({ triggerPath: 1 });
+
+    return settings;
+  }
+}
+
+
+// move this to util
+// remove this from models/page
+const cutOffLastSlash = path => {
+  const lastSlash = path.lastIndexOf('/');
+  return path.substr(0, lastSlash);
+};
+
+const generatePathsOnTree = (path, pathList) => {
+  pathList.push(path);
+
+  if (path === '') {
+    return pathList;
+  }
+
+  const newPath = cutOffLastSlash(path);
+
+  return generatePathsOnTree(newPath, pathList);
+};
+
+const generatePathsToMatch = (originalPath) => {
+  const pathList = generatePathsOnTree(originalPath, []);
+  return pathList.map(path => {
+    if (path !== originalPath) {
+      return path + '/*';
+    }
+    else {
+      return path;
+    }
+  });
+};
+
+
+module.exports = {
+  class: GlobalNotificationSetting,
+  schema: globalNotificationSettingSchema,
+};

+ 1 - 0
lib/models/config.js

@@ -61,6 +61,7 @@ module.exports = function(crowi) {
       'security:passport-ldap:searchFilter' : undefined,
       'security:passport-ldap:attrMapUsername' : undefined,
       'security:passport-ldap:attrMapName' : undefined,
+      'security:passport-ldap:attrMapMail' : undefined,
       'security:passport-ldap:groupSearchBase' : undefined,
       'security:passport-ldap:groupSearchFilter' : undefined,
       'security:passport-ldap:groupDnProperty' : undefined,

+ 4 - 2
lib/models/external-account.js

@@ -65,10 +65,12 @@ class ExternalAccount {
    * @param {string} providerType
    * @param {string} accountId
    * @param {object} usernameToBeRegistered the username of User entity that will be created when accountId is not found
+   * @param {object} nameToBeRegistered the name of User entity that will be created when accountId is not found
+   * @param {object} mailToBeRegistered the mail of User entity that will be created when accountId is not found
    * @returns {Promise<ExternalAccount>}
    * @memberof ExternalAccount
    */
-  static findOrRegister(providerType, accountId, usernameToBeRegistered, nameToBeRegistered) {
+  static findOrRegister(providerType, accountId, usernameToBeRegistered, nameToBeRegistered, mailToBeRegistered) {
 
     return this.findOne({ providerType, accountId })
       .then(account => {
@@ -92,7 +94,7 @@ class ExternalAccount {
 
             // create a new User with STATUS_ACTIVE
             debug(`ExternalAccount '${accountId}' is not found, it is going to be registered.`);
-            return User.createUser(nameToBeRegistered, usernameToBeRegistered, undefined, undefined, undefined, User.STATUS_ACTIVE);
+            return User.createUser(nameToBeRegistered, usernameToBeRegistered, mailToBeRegistered, undefined, undefined, User.STATUS_ACTIVE);
           })
           .then(newUser => {
             return this.associate(providerType, accountId, newUser);

+ 3 - 0
lib/models/index.js

@@ -12,4 +12,7 @@ module.exports = {
   Comment: require('./comment'),
   Attachment: require('./attachment'),
   UpdatePost: require('./updatePost'),
+  GlobalNotificationSetting: require('./GlobalNotificationSetting'),
+  GlobalNotificationMailSetting: require('./GlobalNotificationSetting/GlobalNotificationMailSetting'),
+  GlobalNotificationSlackSetting: require('./GlobalNotificationSetting/GlobalNotificationSlackSetting'),
 };

+ 53 - 7
lib/models/page.js

@@ -70,7 +70,8 @@ module.exports = function(crowi) {
       }
     },
     pageIdOnHackmd: String,
-    revisionHackmdSynced: { type: ObjectId, ref: 'Revision' },
+    revisionHackmdSynced: { type: ObjectId, ref: 'Revision' },  // the revision that is synced to HackMD
+    hasDraftOnHackmd: { type: Boolean },                        // set true if revision and revisionHackmdSynced are same but HackMD document has modified
     createdAt: { type: Date, default: Date.now },
     updatedAt: Date
   }, {
@@ -247,7 +248,7 @@ module.exports = function(crowi) {
   };
 
   pageSchema.methods.getSlackChannel = function() {
-    var extended = this.get('extended');
+    const extended = this.get('extended');
     if (!extended) {
       return '';
     }
@@ -256,14 +257,14 @@ module.exports = function(crowi) {
   };
 
   pageSchema.methods.updateSlackChannel = function(slackChannel) {
-    var extended = this.extended;
+    const extended = this.extended;
     extended.slack = slackChannel;
 
     return this.updateExtended(extended);
   };
 
   pageSchema.methods.updateExtended = function(extended) {
-    var page = this;
+    const page = this;
     page.extended = extended;
     return new Promise(function(resolve, reject) {
       return page.save(function(err, doc) {
@@ -410,7 +411,7 @@ module.exports = function(crowi) {
 
   pageSchema.statics.isCreatableName = function(name) {
     var forbiddenPages = [
-      /\^|\$|\*|\+|#/,
+      /\^|\$|\*|\+|#|%/,
       /^\/-\/.*/,
       /^\/_r\/.*/,
       /^\/_apix?(\/.*)?/,
@@ -1047,7 +1048,7 @@ module.exports = function(crowi) {
     var newRevision = await Revision.prepareRevision(pageData, body, user);
 
     const revision = await Page.pushRevision(pageData, newRevision, user);
-    const savedPage = await Page.findPageByPath(revision.path).populate('revision');
+    const savedPage = await Page.findPageByPath(revision.path).populate('revision').populate('creator');
     if (grant != null) {
       const grantData = await Page.updateGrant(savedPage, grant, user, grantUserGroupId);
       debug('Page grant update:', grantData);
@@ -1059,8 +1060,14 @@ module.exports = function(crowi) {
   pageSchema.statics.deletePage = function(pageData, user, options) {
     var Page = this
       , newPath = Page.getDeletedPageName(pageData.path)
+      , isTrashed = checkIfTrashed(pageData.path)
       ;
+
     if (Page.isDeletableName(pageData.path)) {
+      if (isTrashed) {
+        return Page.completelyDeletePage(pageData, user, options);
+      }
+
       return Page.rename(pageData, newPath, user, {createRedirectPage: true})
         .then((updatedPageData) => {
           return Page.updatePageProperty(updatedPageData, {status: STATUS_DELETED, lastUpdateUser: user});
@@ -1074,12 +1081,21 @@ module.exports = function(crowi) {
     }
   };
 
+  const checkIfTrashed = (path) => {
+    return (path.search(/^\/trash/) !== -1);
+  };
+
   pageSchema.statics.deletePageRecursively = function(pageData, user, options) {
     var Page = this
       , path = pageData.path
       , options = options || {}
+      , isTrashed = checkIfTrashed(pageData.path);
       ;
 
+    if (isTrashed) {
+      return Page.completelyDeletePageRecursively(pageData, user, options);
+    }
+
     return Page.generateQueryToListWithDescendants(path, user, options)
       .then(function(pages) {
         return Promise.all(pages.map(function(page) {
@@ -1314,9 +1330,39 @@ module.exports = function(crowi) {
     }
 
     pageData.pageIdOnHackmd = pageIdOnHackmd;
+
+    return this.syncRevisionToHackmd(pageData);
+  };
+
+  /**
+   * update revisionHackmdSynced
+   * @param {Page} pageData
+   */
+  pageSchema.statics.syncRevisionToHackmd = function(pageData) {
     pageData.revisionHackmdSynced = pageData.revision;
+    pageData.hasDraftOnHackmd = false;
+    return pageData.save();
+  };
+
+  /**
+   * update hasDraftOnHackmd
+   * !! This will be invoked many time from many people !!
+   *
+   * @param {Page} pageData
+   * @param {Boolean} newValue
+   */
+  pageSchema.statics.updateHasDraftOnHackmd = async function(pageData, newValue) {
+    const revisionIdStr = pageData.revision.toString();
+    const revisionHackmdSyncedIdStr = pageData.revisionHackmdSynced.toString();
+    if (revisionIdStr !== revisionHackmdSyncedIdStr) {
+      return;
+    }
+    else if (pageData.hasDraftOnHackmd === newValue) {
+      // do nothing when hasDraftOnHackmd equals to newValue
+      return;
+    }
 
-    // update page
+    pageData.hasDraftOnHackmd = newValue;
     return pageData.save();
   };
 

+ 2 - 3
lib/models/revision.js

@@ -1,7 +1,6 @@
 module.exports = function(crowi) {
-  /* eslint-disable no-unused-vars */
+  // eslint-disable-next-line no-unused-vars
   const logger = require('@alias/logger')('growi:models:revision');
-  /* eslint-enable */
 
   const mongoose = require('mongoose')
     , ObjectId = mongoose.Schema.Types.ObjectId
@@ -12,7 +11,7 @@ module.exports = function(crowi) {
     body: { type: String, required: true, get: (data) => {
       // replace CR/CRLF to LF above v3.1.5
       // see https://github.com/weseek/growi/issues/463
-      return data.replace(/\r\n?/g, '\n');
+      return data ? data.replace(/\r\n?/g, '\n') : '';
     }},
     format: { type: String, default: 'markdown' },
     author: { type: ObjectId, ref: 'User' },

+ 10 - 5
lib/models/user.js

@@ -680,13 +680,19 @@ module.exports = function(crowi) {
     );
   };
 
-  userSchema.statics.createUserByEmailAndPasswordAndStatus = function(name, username, email, password, lang, status, callback) {
-    var User = this
+  userSchema.statics.createUserByEmailAndPasswordAndStatus = async function(name, username, email, password, lang, status, callback) {
+    const User = this
       , newUser = new User();
 
+    // check email duplication because email must be unique
+    const count = await this.count({ email });
+    if (count > 0) {
+      email = generateRandomEmail();
+    }
+
     newUser.name = name;
     newUser.username = username;
-    newUser.email = email || generateRandomEmail();   // don't set undefined for backward compatibility -- 2017.12.27 Yuki Takei
+    newUser.email = email;
     if (password != null) {
       newUser.setPassword(password);
     }
@@ -710,9 +716,8 @@ module.exports = function(crowi) {
   };
 
   /**
-   * A wrapper function of createUserByEmailAndPasswordAndStatus
+   * A wrapper function of createUserByEmailAndPasswordAndStatus with callback
    *
-   * @return {Promise<User>}
    */
   userSchema.statics.createUserByEmailAndPassword = function(name, username, email, password, lang, callback) {
     this.createUserByEmailAndPasswordAndStatus(name, username, email, password, lang, undefined, callback);

+ 129 - 15
lib/routes/admin.js

@@ -2,6 +2,7 @@ module.exports = function(crowi, app) {
   'use strict';
 
   var debug = require('debug')('growi:routes:admin')
+    , logger = require('@alias/logger')('growi:routes:admin')
     , fs = require('fs')
     , models = crowi.models
     , Page = models.Page
@@ -11,6 +12,9 @@ module.exports = function(crowi, app) {
     , UserGroup = models.UserGroup
     , UserGroupRelation = models.UserGroupRelation
     , Config = models.Config
+    , GlobalNotificationSetting = models.GlobalNotificationSetting
+    , GlobalNotificationMailSetting = models.GlobalNotificationMailSetting
+    , GlobalNotificationSlackSetting = models.GlobalNotificationSlackSetting
     , PluginUtils = require('../plugins/plugin-utils')
     , pluginUtils = new PluginUtils()
     , ApiResponse = require('../util/apiResponse')
@@ -187,13 +191,12 @@ module.exports = function(crowi, app) {
 
   // app.get('/admin/notification'               , admin.notification.index);
   actions.notification = {};
-  actions.notification.index = function(req, res) {
-    var config = crowi.getConfig();
-    var UpdatePost = crowi.model('UpdatePost');
-    var slackSetting = Config.setupCofigFormData('notification', config);
-    var hasSlackIwhUrl = Config.hasSlackIwhUrl(config);
-    var hasSlackToken = Config.hasSlackToken(config);
-    var slack = crowi.slack;
+  actions.notification.index = async(req, res) => {
+    const config = crowi.getConfig();
+    const UpdatePost = crowi.model('UpdatePost');
+    let slackSetting = Config.setupCofigFormData('notification', config);
+    const hasSlackIwhUrl = Config.hasSlackIwhUrl(config);
+    const hasSlackToken = Config.hasSlackToken(config);
 
     if (!Config.hasSlackIwhUrl(req.config)) {
       slackSetting['slack:incomingWebhookUrl'] = '';
@@ -204,14 +207,15 @@ module.exports = function(crowi, app) {
       req.session.slackSetting = null;
     }
 
-    UpdatePost.findAll()
-    .then(function(settings) {
-      return res.render('admin/notification', {
-        settings,
-        slackSetting,
-        hasSlackIwhUrl,
-        hasSlackToken,
-      });
+    const globalNotifications = await GlobalNotificationSetting.findAll();
+    const userNotifications = await UpdatePost.findAll();
+
+    return res.render('admin/notification', {
+      userNotifications,
+      slackSetting,
+      hasSlackIwhUrl,
+      hasSlackToken,
+      globalNotifications,
     });
   };
 
@@ -309,6 +313,97 @@ module.exports = function(crowi, app) {
     });
   };
 
+  actions.globalNotification = {};
+  actions.globalNotification.detail = async(req, res) => {
+    const notificationSettingId = req.params.id;
+    let renderVars = {};
+
+    if (notificationSettingId) {
+      try {
+        renderVars.setting = await GlobalNotificationSetting.findOne({_id: notificationSettingId});
+      }
+      catch (err) {
+        logger.error(`Error in finding a global notification setting with {_id: ${notificationSettingId}}`);
+      }
+    }
+
+    return res.render('admin/global-notification-detail', renderVars);
+  };
+
+  actions.globalNotification.create = (req, res) => {
+    const form = req.form.notificationGlobal;
+    let setting;
+
+    switch (form.notifyToType) {
+      case 'mail':
+        setting = new GlobalNotificationMailSetting(crowi);
+        setting.toEmail = form.toEmail;
+        break;
+      // case 'slack':
+      //   setting = new GlobalNotificationSlackSetting(crowi);
+      //   setting.slackChannels = form.slackChannels;
+      //   break;
+      default:
+        logger.error('GlobalNotificationSetting Type Error: undefined type');
+        req.flash('errorMessage', 'Error occurred in creating a new global notification setting: undefined notification type');
+        return res.redirect('/admin/notification#global-notification');
+    }
+
+    setting.triggerPath = form.triggerPath;
+    setting.triggerEvents = getNotificationEvents(form);
+    setting.save();
+
+    return res.redirect('/admin/notification#global-notification');
+  };
+
+  actions.globalNotification.update = async(req, res) => {
+    const form = req.form.notificationGlobal;
+    const setting = await GlobalNotificationSetting.findOne({_id: form.id});
+
+    switch (form.notifyToType) {
+      case 'mail':
+        setting.toEmail = form.toEmail;
+        break;
+      // case 'slack':
+      //   setting.slackChannels = form.slackChannels;
+      //   break;
+      default:
+        logger.error('GlobalNotificationSetting Type Error: undefined type');
+        req.flash('errorMessage', 'Error occurred in updating the global notification setting: undefined notification type');
+        return res.redirect('/admin/notification#global-notification');
+    }
+
+    setting.triggerPath = form.triggerPath;
+    setting.triggerEvents = getNotificationEvents(form);
+    setting.save();
+
+    return res.redirect('/admin/notification#global-notification');
+  };
+
+  actions.globalNotification.remove = async(req, res) => {
+    const id = req.params.id;
+
+    try {
+      await GlobalNotificationSetting.findOneAndRemove({_id: id});
+      return res.redirect('/admin/notification#global-notification');
+    }
+    catch (err) {
+      req.flash('errorMessage', 'Error in deleting global notification setting');
+      return res.redirect('/admin/notification#global-notification');
+    }
+  };
+
+  const getNotificationEvents = (form) => {
+    let triggerEvents = [];
+    const triggerEventKeys = Object.keys(form).filter(key => key.match(/^triggerEvent/));
+    triggerEventKeys.forEach(key => {
+      if (form[key]) {
+        triggerEvents.push(form[key]);
+      }
+    });
+    return triggerEvents;
+  };
+
   actions.search.buildIndex = function(req, res) {
     var search = crowi.getSearcher();
     if (!search) {
@@ -1098,6 +1193,25 @@ module.exports = function(crowi, app) {
     });
   };
 
+  actions.api.toggleIsEnabledForGlobalNotification = async(req, res) => {
+    const id = req.query.id;
+    const isEnabled = (req.query.isEnabled == 'true');
+
+    try {
+      if (isEnabled) {
+        await GlobalNotificationSetting.enable(id);
+      }
+      else {
+        await GlobalNotificationSetting.disable(id);
+      }
+
+      return res.json(ApiResponse.success());
+    }
+    catch (err) {
+      return res.json(ApiResponse.error());
+    }
+  };
+
   /**
    * save settings, update config cache, and response json
    *

+ 6 - 1
lib/routes/comment.js

@@ -7,6 +7,7 @@ module.exports = function(crowi, app) {
     , User = crowi.model('User')
     , Page = crowi.model('Page')
     , ApiResponse = require('../util/apiResponse')
+    , globalNotificationService = crowi.getGlobalNotificationService()
     , actions = {}
     , api = {};
 
@@ -79,10 +80,14 @@ module.exports = function(crowi, app) {
 
     res.json(ApiResponse.success({comment: createdComment}));
 
+    const path = page.path;
+
+    // global notification
+    globalNotificationService.notifyComment(createdComment, path);
+
     // slack notification
     if (slackNotificationForm.isSlackEnabled) {
       const user = await User.findUserByUsername(req.user.username);
-      const path = page.path;
       const channels = slackNotificationForm.slackChannels;
 
       if (channels) {

+ 109 - 18
lib/routes/hackmd.js

@@ -1,5 +1,6 @@
 const logger = require('@alias/logger')('growi:routes:hackmd');
 const path = require('path');
+const fs = require('graceful-fs');
 const swig = require('swig-templates');
 const axios = require('axios');
 
@@ -10,12 +11,16 @@ module.exports = function(crowi, app) {
 
   // load GROWI agent script for HackMD
   const manifest = require(path.join(crowi.publicDir, 'manifest.json'));
-  const agentScriptPath = path.join(crowi.publicDir, manifest['js/agent-for-hackmd.js']);
+  const agentScriptPath = path.join(crowi.publicDir, manifest['js/hackmd-agent.js']);
+  const stylesScriptPath = path.join(crowi.publicDir, manifest['js/hackmd-styles.js']);
   // generate swig template
-  const agentScriptContentTpl = swig.compileFile(agentScriptPath);
+  let agentScriptContentTpl = undefined;
+  let stylesScriptContentTpl = undefined;
 
 
   /**
+   * GET /_hackmd/load-agent
+   *
    * loadAgent action
    * This should be access from HackMD and send agent script
    *
@@ -23,13 +28,16 @@ module.exports = function(crowi, app) {
    * @param {object} res
    */
   const loadAgent = function(req, res) {
+    // generate swig template
+    if (agentScriptContentTpl == null) {
+      agentScriptContentTpl = swig.compileFile(agentScriptPath);
+    }
+
     const origin = `${req.protocol}://${req.get('host')}`;
-    const styleFilePath = origin + manifest['styles/style-hackmd.css'];
 
     // generate definitions to replace
     const definitions = {
       origin,
-      styleFilePath,
     };
     // inject
     const script = agentScriptContentTpl(definitions);
@@ -39,14 +47,38 @@ module.exports = function(crowi, app) {
   };
 
   /**
-   * Create page on HackMD and start to integrate
+   * GET /_hackmd/load-styles
+   *
+   * loadStyles action
+   * This should be access from HackMD and send script to insert styles
+   *
    * @param {object} req
    * @param {object} res
    */
-  const integrate = async function(req, res) {
+  const loadStyles = function(req, res) {
+    // generate swig template
+    if (stylesScriptContentTpl == null) {
+      stylesScriptContentTpl = swig.compileFile(stylesScriptPath);
+    }
+
+    const styleFilePath = path.join(crowi.publicDir, manifest['styles/style-hackmd.css']);
+    const styles = fs.readFileSync(styleFilePath).toString().replace(/\s+/g, ' ');
+
+    // generate definitions to replace
+    const definitions = {
+      styles,
+    };
+    // inject
+    const script = stylesScriptContentTpl(definitions);
+
+    res.set('Content-Type', 'application/javascript');
+    res.send(script);
+  };
+
+  const validateForApi = async function(req, res, next) {
     // validate process.env.HACKMD_URI
-    const hackMdUri = process.env.HACKMD_URI;
-    if (hackMdUri == null) {
+    const hackmdUri = process.env.HACKMD_URI;
+    if (hackmdUri == null) {
       return res.json(ApiResponse.error('HackMD for GROWI has not been setup'));
     }
     // validate pageId
@@ -59,34 +91,93 @@ module.exports = function(crowi, app) {
     if (page == null) {
       return res.json(ApiResponse.error(`Page('${pageId}') does not exist`));
     }
+
+    req.page = page;
+    next();
+  };
+
+  /**
+   * POST /_api/hackmd.integrate
+   *
+   * Create page on HackMD and start to integrate
+   * @param {object} req
+   * @param {object} res
+   */
+  const integrate = async function(req, res) {
+    const hackmdUri = process.env.HACKMD_URI_FOR_SERVER || process.env.HACKMD_URI;
+    let page = req.page;
+
     if (page.pageIdOnHackmd != null) {
-      return res.json(ApiResponse.error(`'pageIdOnHackmd' of the page '${page.path}' is not empty`));
+      try {
+        // check if page exists in HackMD
+        await axios.get(`${hackmdUri}/${page.pageIdOnHackmd}`);
+      }
+      catch (err) {
+        // reset if pages doesn't exist
+        page.pageIdOnHackmd = undefined;
+      }
+    }
+
+    try {
+      if (page.pageIdOnHackmd == null) {
+        page = await createNewPageOnHackmdAndRegister(hackmdUri, page);
+      }
+      else {
+        page = await Page.syncRevisionToHackmd(page);
+      }
+
+      const data = {
+        pageIdOnHackmd: page.pageIdOnHackmd,
+        revisionIdHackmdSynced: page.revisionHackmdSynced,
+        hasDraftOnHackmd: page.hasDraftOnHackmd,
+      };
+      return res.json(ApiResponse.success(data));
+    }
+    catch (err) {
+      logger.error(err);
+      return res.json(ApiResponse.error('Integration with HackMD process failed'));
     }
+  };
 
+  async function createNewPageOnHackmdAndRegister(hackmdUri, page) {
     // access to HackMD and create page
-    const response = await axios.get(`${hackMdUri}/new`);
+    const response = await axios.get(`${hackmdUri}/new`);
     logger.debug('HackMD responds', response);
 
     // extract page id on HackMD
     const pagePathOnHackmd = response.request.path;     // e.g. '/NC7bSRraT1CQO1TO7wjCPw'
     const pageIdOnHackmd = pagePathOnHackmd.substr(1);  // strip the head '/'
 
-    // persist
-    try {
-      await Page.registerHackmdPage(page, pageIdOnHackmd);
+    return Page.registerHackmdPage(page, pageIdOnHackmd);
+  }
 
-      const data = {
-        pageIdOnHackmd,
-      };
-      return res.json(ApiResponse.success(data));
+  /**
+   * POST /_api/hackmd.saveOnHackmd
+   *
+   * receive when save operation triggered on HackMD
+   * !! This will be invoked many time from many people !!
+   *
+   * @param {object} req
+   * @param {object} res
+   */
+  const saveOnHackmd = async function(req, res) {
+    const page = req.page;
+
+    try {
+      await Page.updateHasDraftOnHackmd(page, true);
+      return res.json(ApiResponse.success());
     }
     catch (err) {
-      return res.json(ApiResponse.error(err));
+      logger.error(err);
+      return res.json(ApiResponse.error('saveOnHackmd process failed'));
     }
   };
 
   return {
     loadAgent,
+    loadStyles,
+    validateForApi,
     integrate,
+    saveOnHackmd,
   };
 };

+ 16 - 2
lib/routes/index.js

@@ -109,6 +109,12 @@ module.exports = function(crowi, app) {
   app.post('/_api/admin/notification.add'    , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.api.notificationAdd);
   app.post('/_api/admin/notification.remove' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.api.notificationRemove);
   app.get('/_api/admin/users.search'         , loginRequired(crowi, app) , middleware.adminRequired() , admin.api.usersSearch);
+  app.get('/admin/global-notification/new'   , loginRequired(crowi, app) , middleware.adminRequired() , admin.globalNotification.detail);
+  app.get('/admin/global-notification/:id'   , loginRequired(crowi, app) , middleware.adminRequired() , admin.globalNotification.detail);
+  app.post('/admin/global-notification/new'  , loginRequired(crowi, app) , middleware.adminRequired() , form.admin.notificationGlobal, admin.globalNotification.create);
+  app.post('/_api/admin/global-notification/toggleIsEnabled', loginRequired(crowi, app) , middleware.adminRequired() , admin.api.toggleIsEnabledForGlobalNotification);
+  app.post('/admin/global-notification/:id/update', loginRequired(crowi, app) , middleware.adminRequired() , form.admin.notificationGlobal, admin.globalNotification.update);
+  app.post('/admin/global-notification/:id/remove', loginRequired(crowi, app) , middleware.adminRequired() , admin.globalNotification.remove);
 
   app.get('/admin/users'                , loginRequired(crowi, app) , middleware.adminRequired() , admin.user.index);
   app.post('/admin/user/invite'         , form.admin.userInvite ,  loginRequired(crowi, app) , middleware.adminRequired() , csrf, admin.user.invite);
@@ -199,13 +205,21 @@ module.exports = function(crowi, app) {
   //app.get('/_api/revision/:id'     , user.useUserData()         , revision.api.get);
   //app.get('/_api/r/:revisionId'    , user.useUserData()         , page.api.get);
 
+  /*
+   * WIP: removing pageEdit action
+   * see https://weseek.myjetbrains.com/youtrack/issue/GC-610
+   *
   app.post('/_/edit'                 , form.revision             , loginRequired(crowi, app) , csrf, page.pageEdit);
+  */
+
   app.get('/trash$'                  , loginRequired(crowi, app, false) , page.trashPageShowWrapper);
   app.get('/trash/$'                 , loginRequired(crowi, app, false) , page.trashPageListShowWrapper);
   app.get('/trash/*/$'               , loginRequired(crowi, app, false) , page.deletedPageListShowWrapper);
 
-  app.get('/_hackmd/load-agent'      , hackmd.loadAgent);
-  app.post('/_api/hackmd/integrate'  , accessTokenParser , loginRequired(crowi, app) , csrf, hackmd.integrate);
+  app.get('/_hackmd/load-agent'        , hackmd.loadAgent);
+  app.get('/_hackmd/load-styles'       , hackmd.loadStyles);
+  app.post('/_api/hackmd.integrate'    , accessTokenParser , loginRequired(crowi, app) , csrf, hackmd.validateForApi, hackmd.integrate);
+  app.post('/_api/hackmd.saveOnHackmd' , accessTokenParser , loginRequired(crowi, app) , csrf, hackmd.validateForApi, hackmd.saveOnHackmd);
 
   app.get('/*/$'                   , loginRequired(crowi, app, false) , page.pageListShowWrapper);
   app.get('/*'                     , loginRequired(crowi, app, false) , page.pageShowWrapper);

+ 6 - 2
lib/routes/login-passport.js

@@ -95,12 +95,15 @@ module.exports = function(crowi, app) {
     const ldapAccountId = passportService.getLdapAccountIdFromReq(req);
     const attrMapUsername = passportService.getLdapAttrNameMappedToUsername();
     const attrMapName = passportService.getLdapAttrNameMappedToName();
+    const attrMapMail = passportService.getLdapAttrNameMappedToMail();
     const usernameToBeRegistered = ldapAccountInfo[attrMapUsername];
     const nameToBeRegistered = ldapAccountInfo[attrMapName];
+    const mailToBeRegistered = ldapAccountInfo[attrMapMail];
     const userInfo = {
       'id': ldapAccountId,
       'username': usernameToBeRegistered,
-      'name': nameToBeRegistered
+      'name': nameToBeRegistered,
+      'email': mailToBeRegistered,
     };
 
     const externalAccount = await getOrCreateUser(req, res, next, userInfo, providerId);
@@ -339,7 +342,8 @@ module.exports = function(crowi, app) {
         providerId,
         userInfo.id,
         userInfo.username,
-        userInfo.name
+        userInfo.name,
+        userInfo.email,
       );
       return externalAccount;
     }

+ 218 - 231
lib/routes/page.js

@@ -16,24 +16,25 @@ module.exports = function(crowi, app) {
     , pagePathUtil = require('../util/pagePathUtil')
     , swig = require('swig-templates')
     , getToday = require('../util/getToday')
+    , globalNotificationService = crowi.getGlobalNotificationService()
 
     , actions = {};
 
   // register page events
 
-  var pageEvent = crowi.event('page');
+  const pageEvent = crowi.event('page');
   pageEvent.on('update', function(page, user) {
     crowi.getIo().sockets.emit('page edited', {page, user});
   });
 
 
   function getPathFromRequest(req) {
-    var path = '/' + (req.params[0] || '');
+    const path = '/' + (req.params[0] || '');
     return path.replace(/\.md$/, '');
   }
 
   function isUserPage(path) {
-    if (path.match(/^\/user\/[^\/]+\/?$/)) {
+    if (path.match(/^\/user\/[^/]+\/?$/)) {
       return true;
     }
 
@@ -42,9 +43,9 @@ module.exports = function(crowi, app) {
 
   // TODO: total とかでちゃんと計算する
   function generatePager(options) {
-    var next = null,
-      prev = null,
-      offset = parseInt(options.offset, 10),
+    let next = null,
+      prev = null;
+    const offset = parseInt(options.offset, 10),
       limit  = parseInt(options.limit, 10),
       length = options.length || 0;
 
@@ -70,6 +71,26 @@ module.exports = function(crowi, app) {
     };
   }
 
+  // user notification
+  // TODO create '/service/user-notification' module
+  async function notifyToSlackByUser(page, user, slackChannels, updateOrCreate, previousRevision) {
+    await page.updateSlackChannel(slackChannels)
+      .catch(err => {
+        logger.error('Error occured in updating slack channels: ', err);
+      });
+
+    if (crowi.slack) {
+      const promises = slackChannels.split(',').map(function(chan) {
+        return crowi.slack.postPage(page, user, chan, updateOrCreate, previousRevision);
+      });
+
+      Promise.all(promises)
+      .catch(err => {
+        logger.error('Error occured in sending slack notification: ', err);
+      });
+    }
+  }
+
   /**
    * switch action by behaviorType
    */
@@ -145,26 +166,26 @@ module.exports = function(crowi, app) {
 
 
   actions.pageListShow = function(req, res) {
-    var path = getPathFromRequest(req);
-    var limit = 50;
-    var offset = parseInt(req.query.offset)  || 0;
-    var SEENER_THRESHOLD = 10;
+    let path = getPathFromRequest(req);
+    const limit = 50;
+    const offset = parseInt(req.query.offset)  || 0;
+    const SEENER_THRESHOLD = 10;
     // add slash if root
     path = path + (path == '/' ? '' : '/');
 
     debug('Page list show', path);
     // index page
-    var pagerOptions = {
+    const pagerOptions = {
       offset: offset,
       limit: limit
     };
-    var queryOptions = {
+    const queryOptions = {
       offset: offset,
       limit: limit + 1,
       isPopulateRevisionBody: Config.isEnabledTimeline(config),
     };
 
-    var renderVars = {
+    const renderVars = {
       page: null,
       path: path,
       isPortal: false,
@@ -179,8 +200,9 @@ module.exports = function(crowi, app) {
 
       if (portalPage) {
         renderVars.revision = portalPage.revision;
-        renderVars.revisionHackmdSynced = portalPage.revisionHackmdSynced;
         renderVars.pageIdOnHackmd = portalPage.pageIdOnHackmd;
+        renderVars.revisionHackmdSynced = portalPage.revisionHackmdSynced;
+        renderVars.hasDraftOnHackmd = portalPage.hasDraftOnHackmd;
         return Revision.findRevisionList(portalPage.path, {});
       }
       else {
@@ -258,6 +280,7 @@ module.exports = function(crowi, app) {
       pageRelatedGroup: null,
       template: null,
       revisionHackmdSynced: null,
+      hasDraftOnHackmd: false,
       slack: '',
     };
 
@@ -281,8 +304,9 @@ module.exports = function(crowi, app) {
         renderVars.path = page.path;
         renderVars.revision = page.revision;
         renderVars.author = page.revision.author;
-        renderVars.revisionHackmdSynced = page.revisionHackmdSynced;
         renderVars.pageIdOnHackmd = page.pageIdOnHackmd;
+        renderVars.revisionHackmdSynced = page.revisionHackmdSynced;
+        renderVars.hasDraftOnHackmd = page.hasDraftOnHackmd;
 
         return Revision.findRevisionList(page.path, {})
         .then(function(tree) {
@@ -414,22 +438,22 @@ module.exports = function(crowi, app) {
   };
 
   actions.deletedPageListShow = function(req, res) {
-    var path = '/trash' + getPathFromRequest(req);
-    var limit = 50;
-    var offset = parseInt(req.query.offset)  || 0;
+    const path = '/trash' + getPathFromRequest(req);
+    const limit = 50;
+    const offset = parseInt(req.query.offset)  || 0;
 
     // index page
-    var pagerOptions = {
+    const pagerOptions = {
       offset: offset,
       limit: limit
     };
-    var queryOptions = {
+    const queryOptions = {
       offset: offset,
       limit: limit + 1,
       includeDeletedPage: true,
     };
 
-    var renderVars = {
+    const renderVars = {
       page: null,
       path: path,
       pages: [],
@@ -454,8 +478,8 @@ module.exports = function(crowi, app) {
 
   actions.search = function(req, res) {
     // spec: ?q=query&sort=sort_order&author=author_filter
-    var query = req.query.q;
-    var search = require('../util/search')(crowi);
+    const query = req.query.q;
+    const search = require('../util/search')(crowi);
 
     search.searchPageByKeyword(query)
     .then(function(pages) {
@@ -465,7 +489,7 @@ module.exports = function(crowi, app) {
         return Promise.resolve([]);
       }
 
-      var ids = pages.hits.hits.map(function(page) {
+      const ids = pages.hits.hits.map(function(page) {
         return page._id;
       });
 
@@ -582,10 +606,10 @@ module.exports = function(crowi, app) {
   }
 
   actions.pageShow = function(req, res) {
-    var path = path || getPathFromRequest(req);
+    const path = getPathFromRequest(req);
 
     // FIXME: せっかく getPathFromRequest になってるのにここが生 params[0] だとダサイ
-    var isMarkdown = req.params[0].match(/.+\.md$/) || false;
+    const isMarkdown = req.params[0].match(/.+\.md$/) || false;
 
     res.locals.path = path;
 
@@ -657,99 +681,14 @@ module.exports = function(crowi, app) {
     });
   };
 
-  actions.pageEdit = function(req, res) {
-
-    if (!req.form.isValid) {
-      req.flash('dangerMessage', 'Request is invalid.');
-      return res.redirect(req.headers.referer);
-    }
-
-    var pageForm = req.form.pageForm;
-    var path = pageForm.path;
-    var body = pageForm.body;
-    var currentRevision = pageForm.currentRevision;
-    var grant = pageForm.grant;
-    var grantUserGroupId = pageForm.grantUserGroupId;
-
-    // TODO: make it pluggable
-    var notify = pageForm.notify || {};
-
-    debug('notify: ', notify);
 
-    var redirectPath = pagePathUtil.encodePagePath(path);
-    var pageData = {};
-    var updateOrCreate;
-    var previousRevision = false;
-
-    // set to render
-    res.locals.pageForm = pageForm;
-
-    // 削除済みページはここで編集不可判定される
-    if (!Page.isCreatableName(path)) {
-      res.redirect(redirectPath);
-      return ;
-    }
-
-    var ignoreNotFound = true;
-    Page.findPage(path, req.user, null, ignoreNotFound)
-    .then(function(data) {
-      pageData = data;
-
-      if (data && !data.isUpdatable(currentRevision)) {
-        debug('Conflict occured');
-        req.flash('dangerMessage', 'Conflict occured');
-        return res.redirect(req.headers.referer);
-      }
-
-      if (data) {
-        previousRevision = data.revision;
-        return Page.updatePage(data, body, req.user, { grant, grantUserGroupId });
-      }
-      else {
-        // new page
-        updateOrCreate = 'create';
-        return Page.create(path, body, req.user, { grant, grantUserGroupId });
-      }
-    }).then(function(data) {
-      // data is a saved page data with revision.
-      pageData = data;
-      if (!data) {
-        throw new Error('Data not found');
-      }
-      // TODO: move to events
-      if (notify.slack) {
-        if (notify.slack.on && notify.slack.channel) {
-          data.updateSlackChannel(notify.slack.channel)
-          .catch(err => {
-            logger.error('Error occured in updating slack channels: ', err);
-          });
-
-          if (crowi.slack) {
-            const promises = notify.slack.channel.split(',').map(function(chan) {
-              return crowi.slack.postPage(pageData, req.user, chan, updateOrCreate, previousRevision);
-            });
-
-            Promise.all(promises)
-            .catch(err => {
-              logger.error('Error occured in sending slack notification: ', err);
-            });
-          }
-        }
-      }
-
-      return res.redirect(redirectPath);
-    });
-  };
-
-
-
-  var api = actions.api = {};
+  const api = actions.api = {};
 
   /**
    * redirector
    */
   api.redirector = function(req, res) {
-    var id = req.params.id;
+    const id = req.params.id;
 
     Page.findPageById(id)
     .then(function(pageData) {
@@ -776,13 +715,13 @@ module.exports = function(crowi, app) {
    * @apiParam {String} user
    */
   api.list = function(req, res) {
-    var username = req.query.user || null;
-    var path = req.query.path || null;
-    var limit = 50;
-    var offset = parseInt(req.query.offset) || 0;
+    const username = req.query.user || null;
+    const path = req.query.path || null;
+    const limit = 50;
+    const offset = parseInt(req.query.offset) || 0;
 
-    var pagerOptions = { offset: offset, limit: limit };
-    var queryOptions = { offset: offset, limit: limit + 1};
+    const pagerOptions = { offset: offset, limit: limit };
+    const queryOptions = { offset: offset, limit: limit + 1};
 
     // Accepts only one of these
     if (username === null && path === null) {
@@ -792,7 +731,7 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Parameter user or path is required.'));
     }
 
-    var pageFetcher;
+    let pageFetcher;
     if (path === null) {
       pageFetcher = User.findUserByUsername(username)
       .then(function(user) {
@@ -813,7 +752,7 @@ module.exports = function(crowi, app) {
       }
       pagerOptions.length = pages.length;
 
-      var result = {};
+      const result = {};
       result.pages = pagePathUtil.encodePagesPath(pages);
       return res.json(ApiResponse.success(result));
     }).catch(function(err) {
@@ -830,37 +769,48 @@ module.exports = function(crowi, app) {
    * @apiParam {String} path
    * @apiParam {String} grant
    */
-  api.create = function(req, res) {
-    var body = req.body.body || null;
-    var pagePath = req.body.path || null;
-    var grant = req.body.grant || null;
-    var grantUserGroupId = req.body.grantUserGroupId || null;
+  api.create = async function(req, res) {
+    const body = req.body.body || null;
+    const pagePath = req.body.path || null;
+    const grant = req.body.grant || null;
+    const grantUserGroupId = req.body.grantUserGroupId || null;
+    const isSlackEnabled = !!req.body.isSlackEnabled;   // cast to boolean
+    const slackChannels = req.body.slackChannels || null;
 
     if (body === null || pagePath === null) {
       return res.json(ApiResponse.error('Parameters body and path are required.'));
     }
 
-    var ignoreNotFound = true;
-    Page.findPage(pagePath, req.user, null, ignoreNotFound)
-    .then(function(data) {
-      if (data !== null) {
-        throw new Error('Page exists');
-      }
+    const ignoreNotFound = true;
+    const createdPage = await Page.findPage(pagePath, req.user, null, ignoreNotFound)
+      .then(function(data) {
+        if (data !== null) {
+          throw new Error('Page exists');
+        }
 
-      return Page.create(pagePath, body, req.user, { grant: grant, grantUserGroupId: grantUserGroupId});
-    }).then(function(data) {
-      if (!data) {
-        throw new Error('Failed to create page.');
-      }
-      var result = { page: data.toObject() };
+        return Page.create(pagePath, body, req.user, { grant: grant, grantUserGroupId: grantUserGroupId});
+      })
+      .catch(function(err) {
+        return res.json(ApiResponse.error(err));
+      });
 
-      result.page.lastUpdateUser = User.filterToPublicFields(data.lastUpdateUser);
-      result.page.creator = User.filterToPublicFields(data.creator);
-      return res.json(ApiResponse.success(result));
-    }).catch(function(err) {
-      return res.json(ApiResponse.error(err));
-    });
+    const result = { page: createdPage.toObject() };
+    result.page.lastUpdateUser = User.filterToPublicFields(createdPage.lastUpdateUser);
+    result.page.creator = User.filterToPublicFields(createdPage.creator);
+    res.json(ApiResponse.success(result));
 
+    // global notification
+    try {
+      await globalNotificationService.notifyPageCreate(createdPage);
+    }
+    catch (err) {
+      logger.error(err);
+    }
+
+    // user notification
+    if (isSlackEnabled && slackChannels != null) {
+      await notifyToSlackByUser(createdPage, req.user, slackChannels, 'create', false);
+    }
   };
 
   /**
@@ -877,41 +827,60 @@ module.exports = function(crowi, app) {
    * - If revision_id is specified => update the page,
    * - If revision_id is not specified => force update by the new contents.
    */
-  api.update = function(req, res) {
-    var pageBody = req.body.body || null;
-    var pageId = req.body.page_id || null;
-    var revisionId = req.body.revision_id || null;
-    var grant = req.body.grant || null;
-    var grantUserGroupId = req.body.grantUserGroupId || null;
+  api.update = async function(req, res) {
+    const pageBody = req.body.body || null;
+    const pageId = req.body.page_id || null;
+    const revisionId = req.body.revision_id || null;
+    const grant = req.body.grant || null;
+    const grantUserGroupId = req.body.grantUserGroupId || null;
+    const isSlackEnabled = !!req.body.isSlackEnabled;   // cast to boolean
+    const slackChannels = req.body.slackChannels || null;
 
     if (pageId === null || pageBody === null) {
       return res.json(ApiResponse.error('page_id and body are required.'));
     }
 
-    Page.findPageByIdAndGrantedUser(pageId, req.user)
-    .then(function(pageData) {
-      if (pageData && revisionId !== null && !pageData.isUpdatable(revisionId)) {
-        throw new Error('Revision error.');
-      }
+    let previousRevision = undefined;
+    let updatedPage = await Page.findPageByIdAndGrantedUser(pageId, req.user)
+      .then(function(pageData) {
+        if (pageData && revisionId !== null && !pageData.isUpdatable(revisionId)) {
+          throw new Error('Revision error.');
+        }
 
-      var grantOption = {};
-      if (grant != null) {
-        grantOption.grant = grant;
-      }
-      if (grantUserGroupId != null) {
-        grantOption.grantUserGroupId = grantUserGroupId;
-      }
-      return Page.updatePage(pageData, pageBody, req.user, grantOption);
-    }).then(function(pageData) {
-      var result = {
-        page: pageData.toObject(),
-      };
-      result.page.lastUpdateUser = User.filterToPublicFields(result.page.lastUpdateUser);
-      return res.json(ApiResponse.success(result));
-    }).catch(function(err) {
-      debug('error on _api/pages.update', err);
-      return res.json(ApiResponse.error(err));
-    });
+        const grantOption = {};
+        if (grant != null) {
+          grantOption.grant = grant;
+        }
+        if (grantUserGroupId != null) {
+          grantOption.grantUserGroupId = grantUserGroupId;
+        }
+
+        // store previous revision
+        previousRevision = pageData.revision;
+
+        return Page.updatePage(pageData, pageBody, req.user, grantOption);
+      })
+      .catch(function(err) {
+        debug('error on _api/pages.update', err);
+        res.json(ApiResponse.error(err));
+      });
+
+    const result = { page: updatedPage.toObject() };
+    result.page.lastUpdateUser = User.filterToPublicFields(updatedPage.lastUpdateUser);
+    res.json(ApiResponse.success(result));
+
+    // global notification
+    try {
+      await globalNotificationService.notifyPageEdit(updatedPage);
+    }
+    catch (err) {
+      logger.error(err);
+    }
+
+    // user notification
+    if (isSlackEnabled && slackChannels != null) {
+      await notifyToSlackByUser(updatedPage, req.user, slackChannels, 'update', previousRevision);
+    }
   };
 
   /**
@@ -941,7 +910,7 @@ module.exports = function(crowi, app) {
     }
 
     pageFinder.then(function(pageData) {
-      var result = {};
+      const result = {};
       result.page = pageData;
 
       return res.json(ApiResponse.success(result));
@@ -958,7 +927,7 @@ module.exports = function(crowi, app) {
    * @apiParam {String} page_id Page Id.
    */
   api.seen = function(req, res) {
-    var pageId = req.body.page_id;
+    const pageId = req.body.page_id;
     if (!pageId) {
       return res.json(ApiResponse.error('page_id required'));
     }
@@ -967,7 +936,7 @@ module.exports = function(crowi, app) {
     .then(function(page) {
       return page.seen(req.user);
     }).then(function(user) {
-      var result = {};
+      const result = {};
       result.seenUser = user;
 
       return res.json(ApiResponse.success(result));
@@ -985,15 +954,22 @@ module.exports = function(crowi, app) {
    * @apiParam {String} page_id Page Id.
    */
   api.like = function(req, res) {
-    var id = req.body.page_id;
+    const id = req.body.page_id;
 
     Page.findPageByIdAndGrantedUser(id, req.user)
     .then(function(pageData) {
       return pageData.like(req.user);
-    }).then(function(data) {
-      var result = {page: data};
-      return res.json(ApiResponse.success(result));
-    }).catch(function(err) {
+    })
+    .then(function(page) {
+      const result = {page: page};
+      res.json(ApiResponse.success(result));
+      return page;
+    })
+    .then((page) => {
+      // global notification
+      return globalNotificationService.notifyPageLike(page, req.user);
+    })
+    .catch(function(err) {
       debug('Like failed', err);
       return res.json(ApiResponse.error({}));
     });
@@ -1007,13 +983,13 @@ module.exports = function(crowi, app) {
    * @apiParam {String} page_id Page Id.
    */
   api.unlike = function(req, res) {
-    var id = req.body.page_id;
+    const id = req.body.page_id;
 
     Page.findPageByIdAndGrantedUser(id, req.user)
     .then(function(pageData) {
       return pageData.unlike(req.user);
     }).then(function(data) {
-      var result = {page: data};
+      const result = {page: data};
       return res.json(ApiResponse.success(result));
     }).catch(function(err) {
       debug('Unlike failed', err);
@@ -1029,8 +1005,8 @@ module.exports = function(crowi, app) {
    * @apiParam {String} path
    */
   api.getUpdatePost = function(req, res) {
-    var path = req.query.path;
-    var UpdatePost = crowi.model('UpdatePost');
+    const path = req.query.path;
+    const UpdatePost = crowi.model('UpdatePost');
 
     if (!path) {
       return res.json(ApiResponse.error({}));
@@ -1042,7 +1018,7 @@ module.exports = function(crowi, app) {
         return e.channel;
       });
       debug('Found updatePost data', data);
-      var result = {updatePost: data};
+      const result = {updatePost: data};
       return res.json(ApiResponse.success(result));
     }).catch(function(err) {
       debug('Error occured while get setting', err);
@@ -1059,49 +1035,56 @@ module.exports = function(crowi, app) {
    * @apiParam {String} revision_id
    */
   api.remove = function(req, res) {
-    var pageId = req.body.page_id;
-    var previousRevision = req.body.revision_id || null;
+    const pageId = req.body.page_id;
+    const previousRevision = req.body.revision_id || null;
 
     // get completely flag
-    const isCompletely = (req.body.completely !== undefined);
+    const isCompletely = (req.body.completely != null);
     // get recursively flag
-    const isRecursively = (req.body.recursively !== undefined);
+    const isRecursively = (req.body.recursively != null);
 
     Page.findPageByIdAndGrantedUser(pageId, req.user)
-    .then(function(pageData) {
-      debug('Delete page', pageData._id, pageData.path);
+      .then(function(pageData) {
+        debug('Delete page', pageData._id, pageData.path);
 
-      if (isCompletely) {
-        if (isRecursively) {
-          return Page.completelyDeletePageRecursively(pageData, req.user);
-        }
-        else {
-          return Page.completelyDeletePage(pageData, req.user);
+        if (isCompletely) {
+          if (isRecursively) {
+            return Page.completelyDeletePageRecursively(pageData, req.user);
+          }
+          else {
+            return Page.completelyDeletePage(pageData, req.user);
+          }
         }
-      }
 
-      // else
+        // else
 
-      if (!pageData.isUpdatable(previousRevision)) {
-        throw new Error('Someone could update this page, so couldn\'t delete.');
-      }
+        if (!pageData.isUpdatable(previousRevision)) {
+          throw new Error('Someone could update this page, so couldn\'t delete.');
+        }
 
-      if (isRecursively) {
-        return Page.deletePageRecursively(pageData, req.user);
-      }
-      else {
-        return Page.deletePage(pageData, req.user);
-      }
-    }).then(function(data) {
-      debug('Page deleted', data.path);
-      var result = {};
-      result.page = data;
+        if (isRecursively) {
+          return Page.deletePageRecursively(pageData, req.user);
+        }
+        else {
+          return Page.deletePage(pageData, req.user);
+        }
+      })
+      .then(function(data) {
+        debug('Page deleted', data.path);
+        const result = {};
+        result.page = data;
 
-      return res.json(ApiResponse.success(result));
-    }).catch(function(err) {
-      debug('Error occured while get setting', err, err.stack);
-      return res.json(ApiResponse.error('Failed to delete page.'));
-    });
+        res.json(ApiResponse.success(result));
+        return data;
+      })
+      .then((page) => {
+        // global notification
+        return globalNotificationService.notifyPageDelete(page);
+      })
+      .catch(function(err) {
+        debug('Error occured while get setting', err, err.stack);
+        return res.json(ApiResponse.error('Failed to delete page.'));
+      });
   };
 
   /**
@@ -1112,7 +1095,7 @@ module.exports = function(crowi, app) {
    * @apiParam {String} page_id Page Id.
    */
   api.revertRemove = function(req, res) {
-    var pageId = req.body.page_id;
+    const pageId = req.body.page_id;
 
     // get recursively flag
     const isRecursively = (req.body.recursively !== undefined);
@@ -1128,7 +1111,7 @@ module.exports = function(crowi, app) {
       }
     }).then(function(data) {
       debug('Complete to revert deleted page', data.path);
-      var result = {};
+      const result = {};
       result.page = data;
 
       return res.json(ApiResponse.success(result));
@@ -1150,15 +1133,14 @@ module.exports = function(crowi, app) {
    * @apiParam {Bool} create_redirect
    */
   api.rename = function(req, res) {
-    var pageId = req.body.page_id;
-    var previousRevision = req.body.revision_id || null;
-    var newPagePath = Page.normalizePath(req.body.new_path);
-    var options = {
+    const pageId = req.body.page_id;
+    const previousRevision = req.body.revision_id || null;
+    const newPagePath = Page.normalizePath(req.body.new_path);
+    const options = {
       createRedirectPage: req.body.create_redirect || 0,
       moveUnderTrees: req.body.move_trees || 0,
     };
-    var isRecursiveMove = req.body.move_recursively || 0;
-    var page = {};
+    const isRecursiveMove = req.body.move_recursively || 0;
 
     if (!Page.isCreatableName(newPagePath)) {
       return res.json(ApiResponse.error(`このページ名は作成できません (${newPagePath})`));
@@ -1187,15 +1169,20 @@ module.exports = function(crowi, app) {
 
       })
       .then(function() {
-        var result = {};
+        const result = {};
         result.page = page;
 
         return res.json(ApiResponse.success(result));
       })
+      .then(() => {
+        // global notification
+        globalNotificationService.notifyPageMove(page, req.body.path, req.user);
+      })
       .catch(function(err) {
         return res.json(ApiResponse.error('Failed to update page.'));
       });
     });
+
   };
 
   /**
@@ -1207,8 +1194,8 @@ module.exports = function(crowi, app) {
    * @apiParam {String} new_path
    */
   api.duplicate = function(req, res) {
-    var pageId = req.body.page_id;
-    var newPagePath = Page.normalizePath(req.body.new_path);
+    const pageId = req.body.page_id;
+    const newPagePath = Page.normalizePath(req.body.new_path);
 
     Page.findPageById(pageId)
       .then(function(pageData) {
@@ -1229,7 +1216,7 @@ module.exports = function(crowi, app) {
    * @apiParam {String} revision_id
    */
   api.unlink = function(req, res) {
-    var pageId = req.body.page_id;
+    const pageId = req.body.page_id;
 
     Page.findPageByIdAndGrantedUser(pageId, req.user)
     .then(function(pageData) {
@@ -1239,7 +1226,7 @@ module.exports = function(crowi, app) {
         .then(() => pageData);
     }).then(function(data) {
       debug('Redirect Page deleted', data.path);
-      var result = {};
+      const result = {};
       result.page = data;
 
       return res.json(ApiResponse.success(result));

+ 185 - 0
lib/service/global-notification.js

@@ -0,0 +1,185 @@
+const debug = require('debug')('growi:service:GlobalNotification');
+/**
+ * the service class of GlobalNotificationSetting
+ */
+class GlobalNotificationService {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.config = crowi.getConfig();
+    this.mailer = crowi.getMailer();
+    this.GlobalNotification = crowi.model('GlobalNotificationSetting');
+    this.User = crowi.model('User');
+    this.Config = crowi.model('Config');
+    this.appTitle = this.Config.appTitle(this.config);
+  }
+
+  notifyByMail(notification, mailOption) {
+    this.mailer.send(Object.assign(mailOption, {to: notification.toEmail}));
+  }
+
+  notifyBySlack(notification, slackOption) {
+    // send slack notification here
+  }
+
+  sendNotification(notifications, option) {
+    notifications.forEach(notification => {
+      if (notification.__t === 'mail') {
+        this.notifyByMail(notification, option.mail);
+      }
+      else if (notification.__t === 'slack') {
+        this.notifyBySlack(notification, option.slack);
+      }
+    });
+  }
+
+  /**
+   * send notification at page creation
+   * @memberof GlobalNotification
+   * @param {obejct} page
+   */
+  async notifyPageCreate(page) {
+    const notifications = await this.GlobalNotification.findSettingByPathAndEvent(page.path, 'pageCreate');
+    const lang = 'en-US'; //FIXME
+    const option = {
+      mail: {
+        subject: `#pageCreate - ${page.creator.username} created ${page.path}`,
+        template: `../../locales/${lang}/notifications/pageCreate.txt`,
+        vars: {
+          appTitle: this.appTitle,
+          path: page.path,
+          username: page.creator.username,
+        }
+      },
+      slack: {},
+    };
+
+    this.sendNotification(notifications, option);
+  }
+
+  /**
+   * send notification at page edit
+   * @memberof GlobalNotification
+   * @param {obejct} page
+   */
+  async notifyPageEdit(page) {
+    const notifications = await this.GlobalNotification.findSettingByPathAndEvent(page.path, 'pageEdit');
+    const lang = 'en-US'; //FIXME
+    const option = {
+      mail: {
+        subject: `#pageEdit - ${page.creator.username} edited ${page.path}`,
+        template: `../../locales/${lang}/notifications/pageEdit.txt`,
+        vars: {
+          appTitle: this.appTitle,
+          path: page.path,
+          username: page.creator.username,
+        }
+      },
+      slack: {},
+    };
+
+    this.sendNotification(notifications, option);
+  }
+
+  /**
+   * send notification at page deletion
+   * @memberof GlobalNotification
+   * @param {obejct} page
+   */
+  async notifyPageDelete(page) {
+    const notifications = await this.GlobalNotification.findSettingByPathAndEvent(page.path, 'pageDelete');
+    const lang = 'en-US'; //FIXME
+    const option = {
+      mail: {
+        subject: `#pageDelete - ${page.creator.username} deleted ${page.path}`,  //FIXME
+        template: `../../locales/${lang}/notifications/pageDelete.txt`,
+        vars: {
+          appTitle: this.appTitle,
+          path: page.path,
+          username: page.creator.username,
+        }
+      },
+      slack: {},
+    };
+
+    this.sendNotification(notifications, option);
+  }
+
+  /**
+   * send notification at page move
+   * @memberof GlobalNotification
+   * @param {obejct} page
+   */
+  async notifyPageMove(page, oldPagePath, user) {
+    const notifications = await this.GlobalNotification.findSettingByPathAndEvent(page.path, 'pageMove');
+    const lang = 'en-US'; //FIXME
+    const option = {
+      mail: {
+        subject: `#pageMove - ${user.username} moved ${page.path} to ${page.path}`, //FIXME
+        template: `../../locales/${lang}/notifications/pageMove.txt`,
+        vars: {
+          appTitle: this.appTitle,
+          oldPath: oldPagePath,
+          newPath: page.path,
+          username: user.username,
+        }
+      },
+      slack: {},
+    };
+
+    this.sendNotification(notifications, option);
+  }
+
+  /**
+   * send notification at page like
+   * @memberof GlobalNotification
+   * @param {obejct} page
+   */
+  async notifyPageLike(page, user) {
+    const notifications = await this.GlobalNotification.findSettingByPathAndEvent(page.path, 'pageLike');
+    const lang = 'en-US'; //FIXME
+    const option = {
+      mail: {
+        subject: `#pageLike - ${user.username} liked ${page.path}`,
+        template: `../../locales/${lang}/notifications/pageLike.txt`,
+        vars: {
+          appTitle: this.appTitle,
+          path: page.path,
+          username: page.creator.username,
+        }
+      },
+      slack: {},
+    };
+
+    this.sendNotification(notifications, option);
+  }
+
+  /**
+   * send notification at page comment
+   * @memberof GlobalNotification
+   * @param {obejct} page
+   * @param {obejct} comment
+   */
+  async notifyComment(comment, path) {
+    const notifications = await this.GlobalNotification.findSettingByPathAndEvent(path, 'comment');
+    const lang = 'en-US'; //FIXME
+    const user = await this.User.findOne({_id: comment.creator});
+    const option = {
+      mail: {
+        subject: `#comment - ${user.username} commented on ${path}`,
+        template: `../../locales/${lang}/notifications/comment.txt`,
+        vars: {
+          appTitle: this.appTitle,
+          path: path,
+          username: user.username,
+          comment: comment.comment,
+        }
+      },
+      slack: {},
+    };
+
+    this.sendNotification(notifications, option);
+  }
+}
+
+module.exports = GlobalNotificationService;

+ 10 - 0
lib/service/passport.js

@@ -155,6 +155,16 @@ class PassportService {
     const config = this.crowi.config;
     return config.crowi['security:passport-ldap:attrMapName'] || '';
   }
+  /**
+   * return attribute name for mapping to name of Crowi DB
+   *
+   * @returns
+   * @memberof PassportService
+   */
+  getLdapAttrNameMappedToMail() {
+    const config = this.crowi.config;
+    return config.crowi['security:passport-ldap:attrMapMail'] || 'mail';
+  }
 
   /**
    * CAUTION: this method is capable to use only when `req.body.loginForm` is not null

+ 3 - 3
lib/util/slack.js

@@ -79,7 +79,7 @@ module.exports = function(crowi) {
       var value = line.value.replace(/\r\n|\r/g, '\n');
       /* eslint-enable */
       if (line.added) {
-        diffText += `:pencil2: ...\n${line.value}`;
+        diffText += `${line.value} ... :lower_left_fountain_pen:`;
       }
       else if (line.removed) {
         // diffText += '-' + line.value.replace(/(.+)?\n/g, '- $1\n');
@@ -179,10 +179,10 @@ module.exports = function(crowi) {
 
     const pageUrl = `<${url}${path}|${path}>`;
     if (updateType == 'create') {
-      text = `:white_check_mark: ${user.username} created a new page! ${pageUrl}`;
+      text = `:rocket: ${user.username} created a new page! ${pageUrl}`;
     }
     else {
-      text = `:up: ${user.username} updated ${pageUrl}`;
+      text = `:heavy_check_mark: ${user.username} updated ${pageUrl}`;
     }
 
     return text;

+ 4 - 0
lib/util/swigFunctions.js

@@ -114,6 +114,10 @@ module.exports = function(crowi, app, req, locals) {
     return false;
   };
 
+  locals.isHackmdSetup = function() {
+    return process.env.HACKMD_URI != null;
+  };
+
   locals.isEnabledPlugins = function() {
     let config = crowi.getConfig();
     return Config.isEnabledPlugins(config);

+ 11 - 19
lib/views/_form.html

@@ -14,26 +14,18 @@
 </div>
 {% endif %}
 
-<form action="/_/edit" id="page-form" method="post" class="{% if isUploadable() %}uploadable{% endif %} page-form">
+<div class="page-editor-footer d-flex flex-row align-items-center justify-content-between">
 
-  <input type="hidden" id="form-body" name="pageForm[body]" value="{% if pageForm.body %}{{ pageForm.body }}{% endif %}">
-  <input type="hidden" name="pageForm[path]" value="{{ path | preventXss }}">
-  <input type="hidden" name="pageForm[currentRevision]" value="{{ pageForm.currentRevision|default(page.revision._id.toString()) }}">
-  <div class="page-editor-footer d-flex flex-row align-items-center justify-content-between">
-    <div>
-      <div id="page-editor-options-selector" class="hidden-xs"></div>
-    </div>
+  <div>
+    <div id="page-editor-options-selector" class="hidden-xs"></div>
+  </div>
 
-    <div class="form-inline d-flex align-items-center" id="page-form-setting">
-      <div id="editor-slack-notification" class="mr-2"></div>
-      <div id="page-grant-selector"></div>
-      <input type="hidden" id="page-grant" name="pageForm[grant]" value="{{ page.grant }}">
-      <input type="hidden" id="grant-group" name="pageForm[grantUserGroupId]" value="{{ pageRelatedGroup._id.toString() }}">
-      <input type="hidden" id="edit-form-csrf" name="_csrf" value="{{ csrf() }}">
-      <button type="submit" class="btn btn-primary btn-submit" id="edit-form-submit">{{ t('Update') }}</button>
-    </div>
+  <div id="save-page-controls"
+    data-grant="{{ page.grant }}"
+    data-grant-group="{{ pageRelatedGroup._id.toString() }}"
+    data-grant-group-name="{{ pageRelatedGroup.name }}">
   </div>
-</form>
-<input type="hidden" id="grant-group-name" value="{{ pageRelatedGroup.name }}">{# for storing group name #}
-<div class="file-module hidden">
+
 </div>
+
+<div class="file-module hidden"></div>

+ 170 - 0
lib/views/admin/global-notification-detail.html

@@ -0,0 +1,170 @@
+{% extends '../layout/admin.html' %}
+
+{% block html_title %}{{ customTitle(t('Notification settings')) }}{% endblock %}
+
+{% block content_header %}
+<div class="header-wrap">
+  <header id="page-header">
+    <h1 class="title" id="">{{ t('Notification settings') }}</h1>
+  </header>
+</div>
+{% endblock %}
+
+{% block content_main %}
+<div class="content-main">
+  {% set smessage = req.flash('successMessage') %}
+  {% if smessage.length %}
+  <div class="alert alert-success">
+    {{ smessage }}
+  </div>
+  {% endif %}
+
+  {% set emessage = req.flash('errorMessage') %}
+  {% if emessage.length %}
+  <div class="alert alert-danger">
+    {{ emessage }}
+  </div>
+  {% endif %}
+
+  <div class="row">
+    <div class="col-md-3">
+      {% include './widget/menu.html' with {current: 'notification'} %}
+    </div>
+
+    <div class="col-md-9">
+      <a href="/admin/notification#global-notification" class="btn btn-default">
+        <i class="icon-fw ti-arrow-left" aria-hidden="true"></i>
+        {{ t('notification_setting.back_to_list') }}
+      </a>
+
+      {% if setting %}
+        {% set actionPath = '/admin/global-notification/' + setting.id + '/update' %}
+      {% else %}
+        {% set actionPath = '/admin/global-notification/new' %}
+      {% endif %}
+
+      <div class="row">
+        <div class="m-t-20 form-box col-md-12">
+          <legend>{{ t('notification_setting.notification_detail') }}</legend>
+
+          <form action="{{ actionPath }}" method="post" class="form-horizontal" role="form">
+            <fieldset class="col-sm-4">
+              <div class="form-group">
+                <h3 for="triggerPath">{{ t('notification_setting.trigger_path') }} <small>{{ t('notification_setting.trigger_path_help', '<code>*</code>') }}</small></h3>
+                <input class="form-control" type="text" name="notificationGlobal[triggerPath]" value="{{ setting.triggerPath || '' }}" required>
+              </div>
+
+              <div class="form-group form-inline">
+                <h3>{{ t('notification_setting.notify_to') }}</h3>
+                <div class="radio radio-primary">
+                  <input type="radio" id="mail" name="notificationGlobal[notifyToType]" value="mail" {% if setting.__t == 'mail' %}checked{% endif %} checked>
+                  <label for="mail">
+                    <p class="font-weight-bold">Email</p>
+                  </label>
+                </div>
+                <div class="radio radio-primary">
+                  <input type="radio" id="slack" name="notificationGlobal[notifyToType]" value="slack" {% if setting.__t == 'slack' %}checked{% endif %} disabled>
+                  <label for="slack">
+                    <p class="font-weight-bold">Slack</p>
+                  </label>
+                </div>
+              </div>
+
+              <!-- <div class="form-group notify-to-option {% if setting.__t != 'mail' %}d-none{% endif %}" id="mail-input"> -->
+              <div class="form-group notify-to-option" id="mail-input">
+                <input class="form-control" type="text" name="notificationGlobal[toEmail]" value="{{ setting.toEmail || '' }}">
+                <p class="help">
+                  <b>Hint: </b>
+                  <a href="https://ifttt.com/create" target="_blank">{{ t('notification_setting.email.ifttt_link') }} <i class="icon-share-alt"></i></a>
+                </p>
+              </div>
+
+              <div class="form-group notify-to-option {% if setting.__t != 'slack' %}d-none{% endif %}" id="slack-input">
+                <input class="form-control" type="text" name="notificationGlobal[slackChannels]" value="{{ setting.slackChannels || '' }}" disabled>
+              </div>
+            </fieldset>
+
+            <fieldset class="col-sm-offset-1 col-sm-5">
+              <div class="form-group">
+                <h3>{{ t('notification_setting.trigger_events') }}</h3>
+                <div class="checkbox checkbox-inverse">
+                  <input type="checkbox" id="trigger-event-pageCreate" name="notificationGlobal[triggerEvent:pageCreate]" value="pageCreate"
+                    {% if setting && (setting.triggerEvents.indexOf('pageCreate') != -1) %}checked{% endif %} />
+                  <label for="trigger-event-pageCreate">
+                    <span class="label label-success"><i class="icon-doc"></i> CREATE</span> - {{ t('notification_setting.event_pageCreate') }}
+                  </label>
+                </div>
+                <div class="checkbox checkbox-inverse">
+                  <input type="checkbox" id="trigger-event-pageEdit" name="notificationGlobal[triggerEvent:pageEdit]" value="pageEdit"
+                    {% if setting && (setting.triggerEvents.indexOf('pageEdit') != -1) %}checked{% endif %} />
+                  <label for="trigger-event-pageEdit">
+                    <span class="label label-warning"><i class="icon-pencil"></i> EDIT</span> - {{ t('notification_setting.event_pageEdit') }}
+                  </label>
+                </div>
+                <div class="checkbox checkbox-inverse">
+                  <input type="checkbox" id="trigger-event-pageMove" name="notificationGlobal[triggerEvent:pageMove]" value="pageMove"
+                    {% if setting && (setting.triggerEvents.indexOf('pageMove') != -1) %}checked{% endif %} />
+                  <label for="trigger-event-pageMove">
+                    <span class="label label-warning"><i class="icon-action-redo"></i> MOVE</span> - {{ t('notification_setting.event_pageMove') }}
+                  </label>
+                </div>
+                <div class="checkbox checkbox-inverse">
+                  <input type="checkbox" id="trigger-event-pageDelete" name="notificationGlobal[triggerEvent:pageDelete]" value="pageDelete"
+                    {% if setting && (setting.triggerEvents.indexOf('pageDelete') != -1) %}checked{% endif %} />
+                  <label for="trigger-event-pageDelete">
+                    <span class="label label-danger"><i class="icon-fire"></i> DELETE</span> - {{ t('notification_setting.event_pageDelete') }}
+                  </label>
+                </div>
+                <div class="checkbox checkbox-inverse">
+                    <input type="checkbox" id="trigger-event-pageLike" name="notificationGlobal[triggerEvent:pageLike]" value="pageLike"
+                      {% if setting && (setting.triggerEvents.indexOf('pageLike') != -1) %}checked{% endif %} />
+                    <label for="trigger-event-pageLike">
+                      <span class="label label-info"><i class="icon-like"></i> LIKE</span> - {{ t('notification_setting.event_pageLike') }}
+                    </label>
+                  </div>
+                <div class="checkbox checkbox-inverse">
+                  <input type="checkbox" id="trigger-event-comment" name="notificationGlobal[triggerEvent:comment]" value="comment"
+                    {% if setting && (setting.triggerEvents.indexOf('comment') != -1) %}checked{% endif %} />
+                  <label for="trigger-event-comment">
+                    <span class="label label-default"><i class="icon-fw icon-bubble"></i> POST</span> - {{ t('notification_setting.event_comment') }}
+                  </label>
+                </div>
+              </div>
+            </fieldset>
+
+            <div class="col-sm-offset-5 col-sm-12 m-t-20">
+              <input type="hidden" name="notificationGlobal[id]" value="{{ setting.id }}">
+              <input type="hidden" name="_csrf" value="{{ csrf() }}">
+              <button type="submit" class="btn btn-primary">
+                {% if setting %}
+                  {{ t('Update') }}
+                {% else %}
+                  {{ t('Create') }}
+                {% endif %}
+              </button>
+            </div>
+          </form>
+        </div>
+      </div>
+
+    </div>
+  </div>
+</div>
+
+<script>
+  $('input[name="notificationGlobal[notifyToType]"]').change(function() {
+    var val = $(this).val();
+    $('.notify-to-option').addClass('d-none');
+    $('#' + val + '-input').removeClass('d-none');
+  });
+
+  $('button#global-notificatin-delete').submit(function() {
+    alert(123)
+  });
+</script>
+{% endblock content_main %}
+
+{% block content_footer %}
+{% endblock content_footer %}
+
+

+ 124 - 0
lib/views/admin/global-notification.html

@@ -0,0 +1,124 @@
+<a href="/admin/global-notification/new">
+  <p class="btn btn-default">{{ t('notification_setting.add_notification') }}</p>
+</a>
+<h2>{{ t('notification_setting.notification_list') }}</h2>
+
+{% set tags = {
+  pageCreate: '<span class="label label-success" data-toggle="tooltip" data-placement="top" title="Page Create"><i class="icon-doc"></i> CREATE</span>',
+  pageEdit: '<span class="label label-warning" data-toggle="tooltip" data-placement="top" title="Page Edit"><i class="icon-pencil"></i> EDIT</span>',
+  pageMove: '<span class="label label-warning" data-toggle="tooltip" data-placement="top" title="Page Move"><i class="icon-action-redo"></i> MOVE</span>',
+  pageDelete: '<span class="label label-danger" data-toggle="tooltip" data-placement="top" title="Page Delte"><i class="icon-fire"></i> DELETE</span>',
+  pageLike: '<span class="label label-info" data-toggle="tooltip" data-placement="top" title="Page Like"><i class="icon-like"></i> LIKE</span>',
+  comment: '<span class="label label-default" data-toggle="tooltip" data-placement="top" title="New Comment"><i class="icon-fw icon-bubble"></i> POST</span>'
+} %}
+
+<table class="table table-bordered">
+  <thead>
+    <th>ON/OFF</th>
+    <th>{{ t('notification_setting.trigger_path') }} {{ t('notification_setting.trigger_path_help', '<code>*</code>') }}</th>
+    <th>{{ t('notification_setting.trigger_events') }}</th>
+    <th>{{ t('notification_setting.notify_to') }}</th>
+    <th></th>
+  </thead>
+  <tbody class="admin-notif-list">
+    {% for globalNotif in globalNotifications %}
+    {% set detailPageUrl = '/admin/global-notification/' + globalNotif.id %}
+    <tr>
+      <td class="align-middle td-abs-center">
+        <input type="checkbox" class="js-switch" data-size="small" data-id="{{ globalNotif._id.toString() }}" {% if globalNotif.isEnabled %}checked{% endif %} />
+      </td>
+      <td>
+        {{ globalNotif.triggerPath }}
+      </td>
+      <td style="max-width: 200px;">
+        {% for event in globalNotif.triggerEvents %}
+          {{ tags[event] | safe }}
+        {% endfor %}
+      </td>
+      <td>
+        {% if globalNotif.__t == 'mail' %}<span data-toggle="tooltip" data-placement="top" title="Email"><i class="ti-email"></i> {{ globalNotif.toEmail }}</span>
+        {% elseif globalNotif.__t == 'slack' %}<span data-toggle="tooltip" data-placement="top" title="Slack"><i class="fa fa-slack"></i> {{ globalNotif.slackChannels }}</span>
+        {% endif %}
+      </td>
+      <td class="td-abs-center">
+        <div class="btn-group admin-group-menu">
+          <button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
+            <i class="icon-settings"></i> <span class="caret"></span>
+          </button>
+          <ul class="dropdown-menu" role="menu">
+            <li>
+              <a href="{{ detailPageUrl }}">
+                <i class="icon-fw icon-note"></i> {{ t('Edit') }}
+              </a>
+            </li>
+
+            <li class="btn-delete">
+              <a href="#"
+                  data-setting-id="{{ globalNotif.id }}"
+                  data-target="#admin-delete-global-notification"
+                  data-toggle="modal">
+                <i class="icon-fw icon-fire text-danger"></i> {{ t('Delete') }}
+              </a>
+            </li>
+
+          </ul>
+        </div>
+      </td>
+    </tr>
+    {% endfor %}
+  </tbody>
+</table>
+
+<div class="modal fade" id="admin-delete-global-notification">
+    <div class="modal-dialog">
+      <div class="modal-content">
+        <div class="modal-header bg-danger">
+          <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+          <div class="modal-title">
+            <i class="icon icon-fire"></i> Delete Global Notification Setting
+          </div>
+        </div>
+
+        <div class="modal-body">
+          <span class="text-danger">
+            削除すると元に戻すことはできませんのでご注意ください。
+          </span>
+        </div>
+        <div class="modal-footer">
+          <form action="#" method="post" id="admin-global-notification-setting-delete" class="text-right">
+            <input type="hidden" name="setting-id" value="">
+            <input type="hidden" name="_csrf" value="{{ csrf() }}">
+            <button type="submit" value="" class="btn btn-sm btn-danger">
+              <i class="icon icon-fire"></i> 削除
+            </button>
+          </form>
+        </div>
+
+      </div>
+      <!-- /.modal-content -->
+    </div>
+    <!-- /.modal-dialog -->
+  </div>
+
+<script>
+  $(".btn-delete").on("click", function(event) {
+    var id = $(event.currentTarget).find("a").data("setting-id");
+    $("#admin-global-notification-setting-delete").attr("action", "/admin/global-notification/" + id + "/remove");
+  });
+
+  $(".js-switch").on("change", function(event) {
+    var id = event.currentTarget.dataset.id;
+    var isEnabled = event.currentTarget.checked;
+    $.post('/_api/admin/global-notification/toggleIsEnabled?id=' + id + '&isEnabled=' + isEnabled, function(res) {
+      if (res.ok) {
+        // do nothing
+      }
+      else {
+        $('.admin-notification > .row > .col-md-9').prepend(
+          '<div class=\"alert alert-danger\">Error occurred in deleting global notifcation setting.</div>'
+        );
+        location.reload();
+      }
+    });
+  });
+</script>

+ 7 - 7
lib/views/admin/notification.html

@@ -11,7 +11,7 @@
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main">
+<div class="content-main admin-notification">
   <div class="row">
     <div class="col-md-3">
       {% include './widget/menu.html' with {current: 'notification'} %}
@@ -229,17 +229,17 @@
               </tr>
               </form>
 
-              {% for notif in settings %}
-              <tr class="admin-notif-row" data-updatepost-id="{{ notif._id.toString() }}">
+              {% for userNotif in userNotifications %}
+              <tr class="admin-notif-row" data-updatepost-id="{{ userNotif._id.toString() }}">
                 <td>
-                  {{ notif.pathPattern }}
+                  {{ userNotif.pathPattern }}
                 </td>
                 <td>
-                  {{ notif.channel }}
+                  {{ userNotif.channel }}
                 </td>
                 <td>
                   <form class="admin-remove-updatepost">
-                    <input type="hidden" name="id" value="{{ notif._id.toString() }}">
+                    <input type="hidden" name="id" value="{{ userNotif._id.toString() }}">
                     <input type="hidden" name="_csrf" value="{{ csrf() }}">
                     <input type="submit" value="Delete" class="btn btn-default">
                   </form>
@@ -251,7 +251,7 @@
         </div><!-- /#user-trigger-notification -->
 
         <div id="global-notification" class="tab-pane" role="tabpanel" >
-          <p class="alert alert-info">not implemented now</p>
+          {% include './global-notification.html' %}
         </div><!-- /#global-notification -->
 
       </div><!-- /.tab-content -->

+ 17 - 6
lib/views/admin/widget/passport/ldap.html

@@ -120,7 +120,6 @@
       <h4>Attribute Mapping ({{ t("security_setting.optional") }})</h4>
 
       <div class="form-group">
-        <div class="row">
         <label for="settingForm[security:passport-ldap:attrMapUsername]" class="col-xs-3 control-label">username</label>
         <div class="col-xs-6">
           <input class="form-control" type="text" placeholder="Default: uid"
@@ -131,9 +130,9 @@
             </small>
           </p>
         </div>
-        </div>
+      </div>
 
-        <div class="row">
+      <div class="form-group">
         <div class="col-xs-6 col-xs-offset-3">
           <div class="checkbox checkbox-info">
             <input type="checkbox" id="cbSameUsernameTreatedAsIdenticalUser" name="settingForm[security:passport-ldap:isSameUsernameTreatedAsIdenticalUser]" value="1"
@@ -148,11 +147,23 @@
             </p>
           </div>
         </div>
+      </div>
+
+      <div class="form-group">
+        <label for="settingForm[security:passport-ldap:attrMapMail]" class="col-xs-3 control-label">Mail</label>
+        <div class="col-xs-6">
+          <input class="form-control" type="text" placeholder="Default: mail"
+              name="settingForm[security:passport-ldap:attrMapMail]" value="{{ settingForm['security:passport-ldap:attrMapMail'] || '' }}">
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.ldap.mail_detail") }}
+            </small>
+          </p>
         </div>
       </div>
 
-      <div class="row">
-        <label for="settingForm[security:passport-ldap:attrMapName]" class="col-xs-3 control-label">name</label>
+      <div class="form-group">
+        <label for="settingForm[security:passport-ldap:attrMapName]" class="col-xs-3 control-label">Name</label>
         <div class="col-xs-6">
           <input class="form-control" type="text"
               name="settingForm[security:passport-ldap:attrMapName]" value="{{ settingForm['security:passport-ldap:attrMapName'] || '' }}">
@@ -162,7 +173,7 @@
             </small>
           </p>
         </div>
-        </div>
+      </div>
 
       <h4>{{ t("security_setting.ldap.group_search_filter") }} ({{ t("security_setting.optional") }})</h4>
 

+ 0 - 3
lib/views/layout-growi/not_found.html

@@ -23,7 +23,4 @@
   <div id="presentation-layer" class="fullscreen-layer">
     <div id="presentation-container"></div>
   </div>
-
-  <div id="crowi-modals">
-  </div>
 {% endblock %}

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

@@ -32,7 +32,7 @@
             <div class="d-flex create-page-input-container">
               <div class="create-page-input-row d-flex align-items-center">
                 {% if searchConfigured() %}
-                <div id="page-name-inputter"></div>
+                <div id="page-name-input"></div>
                 {% else %}
                 <input type="text" value="{{ parentPath(path) }}" class="page-name-input form-control " placeholder="{{ t('Input page name') }}" required />
                 {% endif %}

+ 0 - 28
lib/views/modal/page_name_warning.html

@@ -1,28 +0,0 @@
-<div class="modal page-warning-modal" id="page-warning-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>
-        <div class="modal-title">ページ名に関するヒント</div>
-      </div>
-      <div class="modal-body alert alert-danger">
-
-        <strong>Warning!</strong><br>
-
-        <p>
-        スラッシュ <code>/</code> で区切られていない、日付の入ったページ名をつけようとしていませんか?<br>
-        ページ名に日付を入れる場合、スラッシュ <code>/</code> で区切ることが推奨されています。<br>
-        <br>
-        推奨されるページ名で作成する場合、<br>
-        <a href="{{ path|normalizeDateInPath }}">{{ path|normalizeDateInPath }}</a> から作成をはじめてください。
-        </p>
-
-      </div>
-
-      <div class="modal-footer">
-        <a href="{{ path|normalizeDateInPath }}" class="btn btn-primary">Rename!</a>
-      </div>
-    </div>
-  </div>
-</div>

+ 0 - 5
lib/views/widget/forbidden_content.html

@@ -1,8 +1,3 @@
-{% block html_head_loading_legacy %}
-  <script src="{{ webpack_asset('js/legacy-form.js') }}" defer></script>  {# load legacy-form for using bootstrap-select(.selectpicker) #}
-  {% parent %}
-{% endblock %}
-
 <div class="row not-found-message-row m-b-20">
   <div class="col-md-12">
     <h2 class="text-muted">

+ 1 - 1
lib/views/widget/not_found_content.html

@@ -39,7 +39,7 @@
     </div>
 
     {# edit view #}
-    <div class="tab-pane {% if req.body.pageForm %}active{% endif %}" id="edit">
+    <div class="tab-pane" id="edit">
       <div id="page-editor">{% if pageForm.body %}{{ pageForm.body }}{% endif %}</div>
     </div>
     {% include '../_form.html' %}

+ 3 - 4
lib/views/widget/page_content.html

@@ -7,6 +7,7 @@
   data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
   data-page-revision-id-hackmd-synced="{% if revisionHackmdSynced %}{{ revisionHackmdSynced.toString() }}{% endif %}"
   data-page-id-on-hackmd="{% if pageIdOnHackmd %}{{ pageIdOnHackmd.toString() }}{% endif %}"
+  data-page-has-draft-on-hackmd="{% if hasDraftOnHackmd %}{{ hasDraftOnHackmd.toString() }}{% endif %}"
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
   data-slack-channels="{{ slack|default('') }}"
   >
@@ -21,7 +22,7 @@
       <script type="text/template" id="raw-text-original">{{ revision.body.toString() | encodeHTML }}</script>
 
       {# formatted text #}
-      <div class="tab-pane {% if not req.body.pageForm %}active{% endif %}" id="revision-body">
+      <div class="tab-pane active" id="revision-body">
         <div class="revision-toc" id="revision-toc">
           <a data-toggle="collapse" data-parent="#revision-toc" href="#revision-toc-content" class="revision-toc-head">{{ t('Table of Contents') }}</a>
           <div id="revision-toc-content" class="revision-toc-content collapse in"></div>
@@ -32,14 +33,12 @@
 
     {% if not page.isDeleted() %}
       {# edit form #}
-      <div class="tab-pane {% if req.body.pageForm %}active{% endif %}" id="edit">
+      <div class="tab-pane" id="edit">
         <div id="page-editor">{% if pageForm.body %}{{ pageForm.body }}{% endif %}</div>
       </div>
-      {# disabled temporary -- 2018.07.06 Yuki Takei
       <div class="tab-pane" id="hackmd">
         <div id="page-editor-with-hackmd"></div>
       </div>
-      #}
       {% include '../_form.html' %}
     {% endif %}
 

+ 0 - 1
lib/views/widget/page_modals.html

@@ -3,4 +3,3 @@
 {% include '../modal/create_template.html' %}
 {% include '../modal/duplicate.html' %}
 {% include '../modal/put_back.html' %}
-{% include '../modal/page_name_warning.html' %}

+ 5 - 4
lib/views/widget/page_tabs.html

@@ -4,25 +4,25 @@
   {#
     Left Tabs
   #}
-  <li class="nav-main-left-tab {% if not req.body.pageForm %}active{% endif %}">
+  <li class="nav-main-left-tab active">
     <a href="#revision-body" data-toggle="tab">
       <i class="icon-control-play"></i> View
     </a>
   </li>
 
   {% if !isTrashPage() %}
-  <li class="nav-main-left-tab nav-tab-edit {% if req.body.pageForm %}active{% endif %}">
+  <li class="nav-main-left-tab nav-tab-edit">
     <a {% if user %}href="#edit" data-toggle="tab"{% endif %} class="edit-button {% if not user %}edit-button-disabled{% endif %}">
       <i class="icon-note"></i> {{ t('Edit') }}
     </a>
   </li>
-  {# disabled temporary -- 2018.07.06 Yuki Takei
+  {% if isHackmdSetup() %}
   <li class="nav-main-left-tab nav-tab-hackmd">
     <a {% if user %}href="#hackmd" data-toggle="tab"{% endif %} class="{% if not user %}edit-button-disabled{% endif %}">
       <i class="fa fa-file-text-o"></i> {{ t('HackMD') }}
     </a>
   </li>
-  #}
+  {% endif %}
   {% endif %}
 
   {#
@@ -58,6 +58,7 @@
         {% endif %}
       </ul>
     </li>
+    {% endif %}
   {% endif %}
 
   <li class="nav-main-right-tab pull-right">

+ 8 - 6
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.1.13-RC",
+  "version": "3.2.0-RC3",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -87,7 +87,7 @@
     "i18next-sprintf-postprocessor": "^0.2.2",
     "markdown-it-blockdiag": "^1.0.2",
     "md5": "^2.2.1",
-    "method-override": "^2.3.10",
+    "method-override": "^3.0.0",
     "mkdirp": "~0.5.1",
     "module-alias": "^2.0.6",
     "mongoose": "^5.0.0",
@@ -113,7 +113,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.0.16",
-    "autoprefixer": "^8.2.0",
+    "autoprefixer": "^9.0.0",
     "babel-core": "^6.25.0",
     "babel-loader": "^7.1.1",
     "babel-plugin-lodash": "^3.3.2",
@@ -132,7 +132,7 @@
     "colors": "^1.2.5",
     "commander": "^2.11.0",
     "connect-browser-sync": "^2.1.0",
-    "css-loader": "^0.28.0",
+    "css-loader": "^1.0.0",
     "csv-to-markdown-table": "^0.4.0",
     "date-fns": "^1.29.0",
     "diff2html": "^2.3.3",
@@ -142,6 +142,7 @@
     "extract-text-webpack-plugin": "^4.0.0-beta.0",
     "file-loader": "^1.1.0",
     "i18next-browser-languagedetector": "^2.2.0",
+    "imports-loader": "^0.8.0",
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
@@ -166,12 +167,13 @@
     "normalize-path": "^3.0.0",
     "null-loader": "^0.1.1",
     "on-headers": "^1.0.1",
-    "optimize-css-assets-webpack-plugin": "^4.0.2",
+    "optimize-css-assets-webpack-plugin": "^5.0.0",
+    "penpal": "^3.0.3",
     "plantuml-encoder": "^1.2.5",
     "postcss-loader": "^2.1.3",
     "react": "^16.4.1",
     "react-bootstrap": "^0.32.1",
-    "react-bootstrap-typeahead": "=3.0.4",
+    "react-bootstrap-typeahead": "^3.1.5",
     "react-clipboard.js": "^2.0.0",
     "react-codemirror2": "^5.0.4",
     "react-dom": "^16.4.1",

+ 0 - 61
resource/js/agent-for-hackmd.js

@@ -1,61 +0,0 @@
-/**
- * GROWI agent for HackMD
- *
- * This file will be transpiled as a single JS
- *  and should be load from HackMD head via 'lib/routes/hackmd.js' route
- *
- * USAGE:
- *  <script src="${hostname of GROWI}/_hackmd/load-agent"></script>
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- */
-
-/* eslint-disable no-console  */
-console.log('[HackMD] Loading GROWI agent for HackMD...');
-
-const allowedOrigin = '{{origin}}';         // will be replaced by swig
-const styleFilePath = '{{styleFilePath}}';  // will be replaced by swig
-
-/**
- * Validate origin
- * @param {object} event
- */
-function validateOrigin(event) {
-  if (event.origin !== allowedOrigin) {
-    console.error('[HackMD] Message is rejected.', 'Cause: "event.origin" and "allowedOrigin" does not match');
-    return;
-  }
-}
-
-/**
- * Insert link tag to load style file
- */
-function insertStyle() {
-  const element = document.createElement('link');
-  element.href = styleFilePath;
-  element.rel = 'stylesheet';
-  document.getElementsByTagName('head')[0].appendChild(element);
-}
-
-
-insertStyle();
-
-window.addEventListener('message', (event) => {
-  validateOrigin(event);
-
-  const data = JSON.parse(event.data);
-  switch (data.operation) {
-    case 'getValue':
-      console.log('getValue called');
-      break;
-    case 'setValue':
-      console.log('setValue called');
-      break;
-  }
-});
-
-window.addEventListener('load', (event) => {
-  console.log('loaded');
-});
-
-console.log('[HackMD] GROWI agent for HackMD has successfully loaded.');

+ 167 - 89
resource/js/app.js

@@ -1,6 +1,7 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
 import { I18nextProvider } from 'react-i18next';
+import * as toastr from 'toastr';
 
 import i18nFactory from './i18n';
 
@@ -15,14 +16,13 @@ import SearchPage       from './components/SearchPage';
 import PageEditor       from './components/PageEditor';
 import OptionsSelector  from './components/PageEditor/OptionsSelector';
 import { EditorOptions, PreviewOptions } from './components/PageEditor/OptionsSelector';
-import GrantSelector    from './components/PageEditor/GrantSelector';
+import SavePageControls from './components/SavePageControls';
 import PageEditorByHackmd from './components/PageEditorByHackmd';
 import Page             from './components/Page';
 import PageListSearch   from './components/PageListSearch';
 import PageHistory      from './components/PageHistory';
 import PageComments     from './components/PageComments';
 import CommentForm from './components/PageComment/CommentForm';
-import SlackNotification from './components/SlackNotification';
 import PageAttachment   from './components/PageAttachment';
 import SeenUserList     from './components/SeenUserList';
 import RevisionPath     from './components/Page/RevisionPath';
@@ -52,20 +52,21 @@ let pageId = null;
 let pageRevisionId = null;
 let pageRevisionCreatedAt = null;
 let pageRevisionIdHackmdSynced = null;
+let hasDraftOnHackmd = false;
 let pageIdOnHackmd = null;
 let pagePath;
 let pageContent = '';
 let markdown = '';
-let pageGrant = null;
-let slackChannels = '';
+let slackChannels;
 if (mainContent !== null) {
   pageId = mainContent.getAttribute('data-page-id');
   pageRevisionId = mainContent.getAttribute('data-page-revision-id');
   pageRevisionCreatedAt = +mainContent.getAttribute('data-page-revision-created');
   pageRevisionIdHackmdSynced = mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null;
   pageIdOnHackmd = mainContent.getAttribute('data-page-id-on-hackmd') || null;
+  hasDraftOnHackmd = !!mainContent.getAttribute('data-page-has-draft-on-hackmd');
   pagePath = mainContent.attributes['data-path'].value;
-  slackChannels = mainContent.getAttribute('data-slack-channels');
+  slackChannels = mainContent.getAttribute('data-slack-channels') || '';
   const rawText = document.getElementById('raw-text-original');
   if (rawText) {
     pageContent = rawText.innerHTML;
@@ -125,7 +126,7 @@ const componentMappings = {
   'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
   'bookmark-button-lg': <BookmarkButton pageId={pageId} crowi={crowi} size="lg" />,
 
-  'page-name-inputter': <NewPageNameInput crowi={crowi} parentPageName={pagePath} />,
+  'page-name-input': <NewPageNameInput crowi={crowi} parentPageName={pagePath} />,
 
 };
 // additional definitions if data exists
@@ -148,6 +149,163 @@ Object.keys(componentMappings).forEach((key) => {
   }
 });
 
+
+/**
+ * save success handler when reloading is not needed
+ * @param {object} page Page instance
+ */
+const saveWithShortcutSuccessHandler = function(page) {
+  const editorMode = crowi.getCrowiForJquery().getCurrentEditorMode();
+
+  // show toastr
+  toastr.success(undefined, 'Saved successfully', {
+    closeButton: true,
+    progressBar: true,
+    newestOnTop: false,
+    showDuration: '100',
+    hideDuration: '100',
+    timeOut: '1200',
+    extendedTimeOut: '150',
+  });
+
+  pageId = page._id;
+  pageRevisionId = page.revision._id;
+
+  // set page id to SavePageControls
+  componentInstances.savePageControls.setPageId(pageId);  // TODO fix this line failed because of i18next
+
+  // re-render Page component
+  if (componentInstances.page != null) {
+    componentInstances.page.setMarkdown(page.revision.body);
+  }
+  // re-render PageEditor component
+  if (componentInstances.pageEditor != null) {
+    const updateEditorValue = (editorMode !== 'builtin');
+    componentInstances.pageEditor.setMarkdown(page.revision.body, updateEditorValue);
+  }
+  // set revision id to PageEditorByHackmd
+  if (componentInstances.pageEditorByHackmd != null) {
+    componentInstances.pageEditorByHackmd.setRevisionId(pageRevisionId);
+
+    const updateEditorValue = (editorMode !== 'hackmd');
+    componentInstances.pageEditorByHackmd.setMarkdown(page.revision.body, updateEditorValue);
+  }
+};
+
+const errorHandler = function(error) {
+  toastr.error(error.message, 'Error occured', {
+    closeButton: true,
+    progressBar: true,
+    newestOnTop: false,
+    showDuration: '100',
+    hideDuration: '100',
+    timeOut: '3000',
+  });
+};
+
+const saveWithShortcut = function(markdown) {
+  const editorMode = crowi.getCrowiForJquery().getCurrentEditorMode();
+  if (editorMode == null) {
+    // do nothing
+    return;
+  }
+  // get options
+  const options = componentInstances.savePageControls.getCurrentOptionsToSave();
+
+  let promise = undefined;
+  if (pageId == null) {
+    promise = crowi.createPage(pagePath, markdown, options);
+  }
+  else {
+    promise = crowi.updatePage(pageId, pageRevisionId, markdown, options);
+  }
+
+  promise
+    .then(saveWithShortcutSuccessHandler)
+    .catch(errorHandler);
+};
+
+const saveWithSubmitButtonSuccessHandler = function() {
+  crowi.clearDraft(pagePath);
+  location.href = pagePath;
+};
+
+const saveWithSubmitButton = function() {
+  const editorMode = crowi.getCrowiForJquery().getCurrentEditorMode();
+  if (editorMode == null) {
+    // do nothing
+    return;
+  }
+  // get options
+  const options = componentInstances.savePageControls.getCurrentOptionsToSave();
+
+  let promise = undefined;
+  // get markdown
+  if (editorMode === 'builtin') {
+    promise = Promise.resolve(componentInstances.pageEditor.getMarkdown());
+  }
+  else {
+    promise = componentInstances.pageEditorByHackmd.getMarkdown();
+  }
+  // create or update
+  if (pageId == null) {
+    promise = promise.then(markdown => {
+      return crowi.createPage(pagePath, markdown, options);
+    });
+  }
+  else {
+    promise = promise.then(markdown => {
+      return crowi.updatePage(pageId, pageRevisionId, markdown, options);
+    });
+  }
+
+  promise
+    .then(saveWithSubmitButtonSuccessHandler)
+    .catch(errorHandler);
+};
+
+// render SavePageControls
+let savePageControls = null;
+const savePageControlsElem = document.getElementById('save-page-controls');
+if (savePageControlsElem) {
+  const grant = +savePageControlsElem.dataset.grant;
+  const grantGroupId = savePageControlsElem.dataset.grantGroup;
+  const grantGroupName = savePageControlsElem.dataset.grantGroupName;
+  ReactDOM.render(
+    <I18nextProvider i18n={i18n}>
+      <SavePageControls crowi={crowi} onSubmit={saveWithSubmitButton}
+          ref={(elem) => {
+            if (savePageControls == null) {
+              savePageControls = elem.getWrappedInstance();
+            }
+          }}
+          pageId={pageId} pagePath={pagePath} slackChannels={slackChannels}
+          grant={grant} grantGroupId={grantGroupId} grantGroupName={grantGroupName} />
+    </I18nextProvider>,
+    savePageControlsElem
+  );
+  componentInstances.savePageControls = savePageControls;
+}
+
+/*
+ * HackMD Editor
+ */
+// render PageEditorWithHackmd
+let pageEditorByHackmd = null;
+const pageEditorWithHackmdElem = document.getElementById('page-editor-with-hackmd');
+if (pageEditorWithHackmdElem) {
+  pageEditorByHackmd = ReactDOM.render(
+    <PageEditorByHackmd crowi={crowi}
+        pageId={pageId} revisionId={pageRevisionId}
+        pageIdOnHackmd={pageIdOnHackmd} revisionIdHackmdSynced={pageRevisionIdHackmdSynced} hasDraftOnHackmd={hasDraftOnHackmd}
+        markdown={markdown}
+        onSaveWithShortcut={saveWithShortcut} />,
+    pageEditorWithHackmdElem
+  );
+  componentInstances.pageEditorByHackmd = pageEditorByHackmd;
+}
+
+
 /*
  * PageEditor
  */
@@ -157,25 +315,16 @@ const previewOptions = new PreviewOptions(crowi.previewOptions);
 // render PageEditor
 const pageEditorElem = document.getElementById('page-editor');
 if (pageEditorElem) {
-  // create onSave event handler
-  const onSaveSuccess = function(page) {
-    // modify the revision id value to pass checking id when updating
-    crowi.getCrowiForJquery().updatePageForm(page);
-    // re-render Page component if exists
-    if (componentInstances.page != null) {
-      componentInstances.page.setMarkdown(page.revision.body);
-    }
-  };
-
   pageEditor = ReactDOM.render(
     <PageEditor crowi={crowi} crowiRenderer={crowiRenderer}
         pageId={pageId} revisionId={pageRevisionId} pagePath={pagePath}
         markdown={markdown}
         editorOptions={editorOptions} previewOptions={previewOptions}
-        onSaveSuccess={onSaveSuccess} />,
+        onSaveWithShortcut={saveWithShortcut} />,
     pageEditorElem
   );
-  // set refs for pageEditor
+  componentInstances.pageEditor = pageEditor;
+  // set refs for setCaretLine/forceToFocus when tab is changed
   crowi.setPageEditor(pageEditor);
 }
 
@@ -200,20 +349,6 @@ if (writeCommentElem) {
     writeCommentElem);
 }
 
-// render slack notification form
-const editorSlackElem = document.getElementById('editor-slack-notification');
-if (editorSlackElem) {
-  ReactDOM.render(
-    <SlackNotification
-      crowi={crowi}
-      pageId={pageId}
-      pagePath={pagePath}
-      isSlackEnabled={false}
-      slackChannels={slackChannels}
-      formName='pageForm' />,
-    editorSlackElem);
-}
-
 // render OptionsSelector
 const pageEditorOptionsSelectorElem = document.getElementById('page-editor-options-selector');
 if (pageEditorOptionsSelectorElem) {
@@ -231,63 +366,6 @@ if (pageEditorOptionsSelectorElem) {
     pageEditorOptionsSelectorElem
   );
 }
-// render GrantSelector
-const pageEditorGrantSelectorElem = document.getElementById('page-grant-selector');
-if (pageEditorGrantSelectorElem) {
-  const grantElem = document.getElementById('page-grant');
-  const grantGroupElem = document.getElementById('grant-group');
-  const grantGroupNameElem = document.getElementById('grant-group-name');
-  /* eslint-disable no-inner-declarations */
-  function updateGrantElem(pageGrant) {
-    grantElem.value = pageGrant;
-  }
-  function updateGrantGroupElem(grantGroupId) {
-    grantGroupElem.value = grantGroupId;
-  }
-  function updateGrantGroupNameElem(grantGroupName) {
-    grantGroupNameElem.value = grantGroupName;
-  }
-  /* eslint-enable */
-  const pageGrant = +grantElem.value;
-  const pageGrantGroupId = grantGroupElem.value;
-  const pageGrantGroupName = grantGroupNameElem.value;
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <GrantSelector crowi={crowi}
-        pageGrant={pageGrant} pageGrantGroupId={pageGrantGroupId} pageGrantGroupName={pageGrantGroupName}
-        onChangePageGrant={updateGrantElem}
-        onDeterminePageGrantGroupId={updateGrantGroupElem}
-        onDeterminePageGrantGroupName={updateGrantGroupNameElem} />
-    </I18nextProvider>,
-    pageEditorGrantSelectorElem
-  );
-}
-
-/*
- * HackMD Editor
- */
-// render PageEditorWithHackmd
-const pageEditorWithHackmdElem = document.getElementById('page-editor-with-hackmd');
-if (pageEditorWithHackmdElem) {
-  // create onSave event handler
-  const onSaveSuccess = function(page) {
-    // modify the revision id value to pass checking id when updating
-    crowi.getCrowiForJquery().updatePageForm(page);
-    // re-render Page component if exists
-    if (componentInstances.page != null) {
-      componentInstances.page.setMarkdown(page.revision.body);
-    }
-  };
-
-  pageEditor = ReactDOM.render(
-    <PageEditorByHackmd crowi={crowi}
-        pageId={pageId} revisionId={pageRevisionId}
-        revisionIdHackmdSynced={pageRevisionIdHackmdSynced} pageIdOnHackmd={pageIdOnHackmd}
-        markdown={markdown}
-        onSaveSuccess={onSaveSuccess} />,
-    pageEditorWithHackmdElem
-  );
-}
 
 // render for admin
 const customCssEditorElem = document.getElementById('custom-css-editor');

+ 0 - 69
resource/js/components/Common/Modal.js

@@ -1,69 +0,0 @@
-import React from 'react';
-
-export default class Modal extends React.Component {
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      modalShown: false,
-    };
-  }
-
-  render() {
-    if (!this.state.modalShown) {
-      return '';
-    }
-
-    return (
-      <div class="modal in" id="renamePage" style="display: block;">
-        <div class="modal-dialog">
-          <div class="modal-content">
-
-          <form role="form" id="renamePageForm" onsubmit="return false;">
-
-            <div class="modal-header">
-              <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
-              <div class="modal-title">Rename page</div>
-            </div>
-            <div class="modal-body">
-                <div class="form-group">
-                  <label for="">Current page name</label><br>
-                  <code>/user/sotarok/memo/2017/04/24</code>
-                </div>
-                <div class="form-group">
-                  <label for="newPageName">New page name</label><br>
-                  <div class="input-group">
-                    <span class="input-group-addon">http://localhost:3000</span>
-                    <input type="text" class="form-control" name="new_path" id="newPageName" value="/user/sotarok/memo/2017/04/24">
-                  </div>
-                </div>
-                <div class="checkbox">
-                  <input name="create_redirect" id="cbRedirect" value="1" type="checkbox">
-                  <label for="cbRedirect">Redirect</label>
-                  <p class="help-block">
-                  Redirect to new page if someone accesses <code>/user/sotarok/memo/2017/04/24</code>
-                  </p>
-                </div>
-
-
-
-
-
-
-
-            </div>
-            <div class="modal-footer">
-              <p><small class="pull-left" id="newPageNameCheck"></small></p>
-              <input type="hidden" name="_csrf" value="RCs7uFdR-4nacCnqKfREe8VIlcYLP2J8xzpU">
-              <input type="hidden" name="path" value="/user/sotarok/memo/2017/04/24">
-              <input type="hidden" name="page_id" value="58fd0bd74c844b8f94c2e5b3">
-              <input type="hidden" name="revision_id" value="58fd126385edfb9d8a0c073a">
-              <input type="submit" class="btn btn-primary" value="Rename!">
-            </div>
-
-          </form>
-          </div><!-- /.modal-content -->
-        </div><!-- /.modal-dialog -->
-      </div>
-  );
-}

+ 34 - 2
resource/js/components/HeaderSearchBox/SearchForm.js

@@ -21,6 +21,7 @@ export default class SearchForm extends React.Component {
 
     this.onSearchError = this.onSearchError.bind(this);
     this.onChange = this.onChange.bind(this);
+    this.onSubmit = this.onSubmit.bind(this);
   }
 
   componentDidMount() {
@@ -44,6 +45,34 @@ export default class SearchForm extends React.Component {
     }
   }
 
+  getHelpElement() {
+    return (
+      <table className="table m-1 search-help">
+        <caption className="text-left text-primary p-2 mb-2">
+          <h5 className="m-1"><i className="icon-magnifier pr-2 mb-2"/>Search Help</h5>
+        </caption>
+        <tbody>
+          <tr>
+            <td className="text-right mt-0 pr-2 p-1"><code>keyword</code></td>
+            <th className="mr-2"><h6 className="pr-2 m-0 pt-1">記事名 or 本文に<samp>"keyword"</samp>を含む</h6></th>
+          </tr>
+          <tr>
+            <td className="text-right mt-0 pr-2 p-1"><code>a b</code></td>
+            <th><h6 className="m-0 pt-1">文字列<samp>"a"</samp>と<samp>"b"</samp>を含む (スペース区切り)</h6></th>
+          </tr>
+          <tr>
+            <td className="text-right mt-0 pr-2 p-1"><code>-keyword</code></td>
+            <th><h6 className="m-0 pt-1">文字列<samp>"keyword"</samp>を含まない</h6></th>
+          </tr>
+        </tbody>
+      </table>
+    );
+  }
+
+  onSubmit(query) {
+    this.refs.form.submit(query);
+  }
+
   render() {
     const emptyLabel = (this.state.searchError !== null)
       ? 'Error on searching.'
@@ -51,16 +80,19 @@ export default class SearchForm extends React.Component {
 
     return (
       <form
-        action="/_search"
-        className="search-form form-group input-group search-input-group"
+        ref='form'
+        action='/_search'
+        className='search-form form-group input-group search-input-group'
       >
         <FormGroup>
           <InputGroup>
             <SearchTypeahead
               crowi={this.crowi}
               onChange={this.onChange}
+              onSubmit={this.onSubmit}
               emptyLabel={emptyLabel}
               placeholder="Search ..."
+              promptText={this.getHelpElement()}
             />
             <InputGroup.Button>
               <Button type="submit" bsStyle="link">

+ 20 - 7
resource/js/components/NewPageNameInput.js

@@ -15,6 +15,7 @@ export default class NewPageNameInput extends React.Component {
     this.crowi = this.props.crowi;
 
     this.onSearchError = this.onSearchError.bind(this);
+    this.onSubmit = this.onSubmit.bind(this);
     this.getParentPageName = this.getParentPageName.bind(this);
   }
 
@@ -30,6 +31,14 @@ export default class NewPageNameInput extends React.Component {
     });
   }
 
+  onSubmit(query) {
+    // get the closest form element
+    const elem = this.refs.rootDom;
+    const form = elem.closest('form');
+    // submit with jQuery
+    $(form).submit();
+  }
+
   getParentPageName(path) {
     if (path == '/') {
       return path;
@@ -48,13 +57,17 @@ export default class NewPageNameInput extends React.Component {
       : 'No matches found on title...';
 
     return (
-      <SearchTypeahead
-        crowi={this.crowi}
-        onSearchError={this.onSearchError}
-        emptyLabel={emptyLabel}
-        placeholder="Input page name"
-        keywordOnInit={this.getParentPageName(this.props.parentPageName)}
-      />
+      <div ref='rootDom'>
+        <SearchTypeahead
+          ref={this.searchTypeaheadDom}
+          crowi={this.crowi}
+          onSearchError={this.onSearchError}
+          onSubmit={this.onSubmit}
+          emptyLabel={emptyLabel}
+          placeholder="Input page name"
+          keywordOnInit={this.getParentPageName(this.props.parentPageName)}
+        />
+      </div>
     );
   }
 }

+ 17 - 81
resource/js/components/PageEditor.js

@@ -1,7 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import * as toastr from 'toastr';
 import { throttle, debounce } from 'throttle-debounce';
 
 import GrowiRenderer from '../util/GrowiRenderer';
@@ -37,15 +36,12 @@ export default class PageEditor extends React.Component {
     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.onEditorScrollCursorIntoView = this.onEditorScrollCursorIntoView.bind(this);
     this.onPreviewScroll = this.onPreviewScroll.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);
 
     // for scrolling
     this.lastScrolledDateWithCursor = null;
@@ -56,7 +52,7 @@ export default class PageEditor extends React.Component {
     this.scrollPreviewByEditorLineWithThrottle = throttle(20, this.scrollPreviewByEditorLine);
     this.scrollPreviewByCursorMovingWithThrottle = throttle(20, this.scrollPreviewByCursorMoving);
     this.scrollEditorByPreviewScrollWithThrottle = throttle(20, this.scrollEditorByPreviewScroll);
-    this.renderWithDebounce = debounce(50, throttle(100, this.renderPreview));
+    this.renderPreviewWithDebounce = debounce(50, throttle(100, this.renderPreview));
     this.saveDraftWithDebounce = debounce(800, this.saveDraft);
   }
 
@@ -65,6 +61,17 @@ export default class PageEditor extends React.Component {
     this.renderPreview(this.state.markdown);
   }
 
+  getMarkdown() {
+    return this.state.markdown;
+  }
+
+  setMarkdown(markdown, updateEditorValue = true) {
+    this.setState({ markdown });
+    if (updateEditorValue) {
+      this.refs.editor.setValue(markdown);
+    }
+  }
+
   focusToEditor() {
     this.refs.editor.forceToFocus();
   }
@@ -99,53 +106,10 @@ export default class PageEditor extends React.Component {
    * @param {string} value
    */
   onMarkdownChanged(value) {
-    this.renderWithDebounce(value);
+    this.renderPreviewWithDebounce(value);
     this.saveDraftWithDebounce();
   }
 
-  /**
-   * the save event handler
-   */
-  onSave() {
-    let endpoint;
-    let data;
-
-    // update
-    if (this.state.pageId != null) {
-      endpoint = '/pages.update';
-      data = {
-        page_id: this.state.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
@@ -293,34 +257,6 @@ export default class PageEditor extends React.Component {
     this.props.crowi.clearDraft(this.props.pagePath);
   }
 
-  pageSavedHandler(page) {
-    // update states
-    this.setState({
-      pageId: page.id,
-      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) {
-    toastr.error(error.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
-  }
-
   renderPreview(value) {
     this.setState({ markdown: value });
 
@@ -351,8 +287,6 @@ export default class PageEditor extends React.Component {
       .then(() => 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(() => interceptorManager.process('postRenderPreviewHtml', context));
@@ -374,8 +308,10 @@ export default class PageEditor extends React.Component {
             onScroll={this.onEditorScroll}
             onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
             onChange={this.onMarkdownChanged}
-            onSave={this.onSave}
             onUpload={this.onUpload}
+            onSave={() => {
+              this.props.onSaveWithShortcut(this.state.markdown);
+            }}
           />
         </div>
         <div className="col-md-6 hidden-sm hidden-xs page-editor-preview-container">
@@ -395,11 +331,11 @@ export default class PageEditor extends React.Component {
 PageEditor.propTypes = {
   crowi: PropTypes.object.isRequired,
   crowiRenderer: PropTypes.object.isRequired,
+  onSaveWithShortcut: PropTypes.func.isRequired,
   markdown: PropTypes.string.isRequired,
   pageId: PropTypes.string,
   revisionId: PropTypes.string,
   pagePath: PropTypes.string,
-  onSaveSuccess: PropTypes.func,
   editorOptions: PropTypes.instanceOf(EditorOptions),
   previewOptions: PropTypes.instanceOf(PreviewOptions),
 };

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

@@ -1,6 +1,8 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import Modal from 'react-bootstrap/es/Modal';
+
 import AbstractEditor from './AbstractEditor';
 
 import urljoin from 'url-join';
@@ -19,6 +21,7 @@ window.CodeMirror = require('codemirror');
 
 
 import { UnControlled as ReactCodeMirror } from 'react-codemirror2';
+require('codemirror/addon/display/placeholder');
 require('codemirror/addon/edit/matchbrackets');
 require('codemirror/addon/edit/matchtags');
 require('codemirror/addon/edit/closetag');
@@ -34,6 +37,7 @@ 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/addon/display/placeholder');
 require('codemirror/mode/gfm/gfm');
 require('../../util/codemirror/autorefresh.ext');
 
@@ -57,7 +61,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
       isGfmMode: this.props.isGfmMode,
       isEnabledEmojiAutoComplete: false,
       isLoadingKeymap: false,
-      additionalClass: '',
+      isSimpleCheatsheetShown: this.props.isGfmMode && this.props.value.length === 0,
+      isCheatsheetModalButtonShown: this.props.isGfmMode && this.props.value.length > 0,
+      isCheatsheetModalShown: false,
+      additionalClassSet: new Set(),
     };
 
     this.init();
@@ -76,8 +83,12 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.scrollCursorIntoViewHandler = this.scrollCursorIntoViewHandler.bind(this);
     this.pasteHandler = this.pasteHandler.bind(this);
     this.cursorHandler = this.cursorHandler.bind(this);
+    this.changeHandler = this.changeHandler.bind(this);
+
+    this.updateCheatsheetStates = this.updateCheatsheetStates.bind(this);
 
     this.renderLoadingKeymapOverlay = this.renderLoadingKeymapOverlay.bind(this);
+    this.renderCheatsheetModalButton = this.renderCheatsheetModalButton.bind(this);
   }
 
   init() {
@@ -150,10 +161,15 @@ export default class CodeMirrorEditor extends AbstractEditor {
    * @inheritDoc
    */
   setGfmMode(bool) {
+    // update state
     this.setState({
       isGfmMode: bool,
       isEnabledEmojiAutoComplete: bool,
     });
+
+    this.updateCheatsheetStates(bool, null);
+
+    // update CodeMirror option
     const mode = bool ? 'gfm' : undefined;
     this.getCodeMirror().setOption('mode', mode);
   }
@@ -388,11 +404,34 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
   cursorHandler(editor, event) {
     const strFromBol = this.getStrFromBol();
+
+    const autoformatTableClass = 'autoformat-markdown-table-activated';
+    const additionalClassSet = this.state.additionalClassSet;
+    const hasCustomClass = additionalClassSet.has(autoformatTableClass);
     if (mtu.isEndOfLine(editor) && mtu.linePartOfTableRE.test(strFromBol)) {
-      this.setState({additionalClass: 'autoformat-markdown-table-activated'});
+      if (!hasCustomClass) {
+        additionalClassSet.add(autoformatTableClass);
+        this.setState({additionalClassSet});
+      }
     }
     else {
-      this.setState({additionalClass: ''});
+      if (hasCustomClass) {
+        additionalClassSet.delete(autoformatTableClass);
+        this.setState({additionalClassSet});
+      }
+    }
+  }
+
+  changeHandler(editor, data, value) {
+    if (this.props.onChange != null) {
+      this.props.onChange(value);
+    }
+
+    this.updateCheatsheetStates(null, value);
+
+    // Emoji AutoComplete
+    if (this.state.isEnabledEmojiAutoComplete) {
+      this.emojiAutoCompleteHelper.showHint(editor);
     }
   }
 
@@ -415,41 +454,212 @@ export default class CodeMirrorEditor extends AbstractEditor {
     }
   }
 
-  getOverlayStyle() {
-    return {
-      position: 'absolute',
-      zIndex: 4,  // forward than .CodeMirror-gutters
+  /**
+   * update states which related to cheatsheet
+   * @param {boolean} isGfmMode (use state.isGfmMode if null is set)
+   * @param {string} value (get value from codemirror if null is set)
+   */
+  updateCheatsheetStates(isGfmMode, value) {
+    if (isGfmMode == null) {
+      isGfmMode = this.state.isGfmMode;
+    }
+    if (value == null) {
+      value = this.getCodeMirror().getDoc().getValue();
+    }
+    // update isSimpleCheatsheetShown, isCheatsheetModalButtonShown
+    const isSimpleCheatsheetShown = isGfmMode && value.length === 0;
+    const isCheatsheetModalButtonShown = isGfmMode && value.length > 0;
+    this.setState({ isSimpleCheatsheetShown, isCheatsheetModalButtonShown });
+  }
+
+  renderLoadingKeymapOverlay() {
+    // centering
+    const style = {
       top: 0,
       right: 0,
       bottom: 0,
       left: 0,
     };
-  }
-
-  renderLoadingKeymapOverlay() {
-    const overlayStyle = this.getOverlayStyle();
 
     return this.state.isLoadingKeymap
-      ? <div style={overlayStyle} className="loading-keymap overlay">
-          <span className="overlay-content">
+      ? <div className="overlay overlay-loading-keymap">
+          <span style={style} className="overlay-content">
             <div className="speeding-wheel d-inline-block"></div> Loading Keymap ...
           </span>
         </div>
       : '';
   }
 
+  renderSimpleCheatsheet() {
+    return (
+      <div className="panel panel-default gfm-cheatsheet mb-0">
+        <div className="panel-body small p-b-0">
+          <div className="row">
+            <div className="col-xs-6">
+              <p>
+                # 見出し1<br />
+                ## 見出し2
+              </p>
+              <p><i>*斜体*</i>&nbsp;&nbsp;<b>**強調**</b></p>
+              <p>
+                [リンク](http://..)<br />
+                [/ページ名/子ページ名]
+              </p>
+              <p>
+                ```javascript:index.js<br />
+                writeCode();<br />
+                ```
+              </p>
+            </div>
+            <div className="col-xs-6">
+              <p>
+                - リスト 1<br />
+                &nbsp;&nbsp;&nbsp;&nbsp;- リスト 1_1<br />
+                - リスト 2<br />
+                1. 番号付きリスト 1
+                1. 番号付きリスト 2
+              </p>
+              <hr />
+              <p>行末にスペース2つ[ ][ ]<br />で改行</p>
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  renderCheatsheetModalBody() {
+    return (
+      <div className="row small">
+        <div className="col-sm-6">
+          <h4>Header</h4>
+          <ul className="hljs">
+            <li><code># </code>見出し1</li>
+            <li><code>## </code>見出し2</li>
+            <li><code>### </code>見出し3</li>
+          </ul>
+          <h4>Block</h4>
+          <p className="mb-1"><code>[空白行]</code>を挟むことで段落になります</p>
+          <ul className="hljs">
+            <li>text</li>
+            <li></li>
+            <li>text</li>
+          </ul>
+          <h4>Line breaks</h4>
+          <p className="mb-1">段落中、<code>[space][space]</code>(スペース2つ) で改行されます</p>
+          <ul className="hljs">
+            <li>text<code> </code><code> </code></li>
+            <li>text</li>
+          </ul>
+          <h4>Typography</h4>
+          <ul className="hljs">
+            <li><i>*イタリック*</i></li>
+            <li><b>**ボールド**</b></li>
+            <li><i><b>***イタリックボールド***</b></i></li>
+            <li>~~取り消し線~~ => <s>striked text</s></li>
+          </ul>
+          <h4>Link</h4>
+          <ul className="hljs">
+            <li>[Google](https://www.google.co.jp/)</li>
+            <li>[/Page1/ChildPage1]</li>
+          </ul>
+          <h4>コードハイライト</h4>
+          <ul className="hljs">
+            <li>```javascript:index.js</li>
+            <li>writeCode();</li>
+            <li>```</li>
+          </ul>
+        </div>
+        <div className="col-sm-6">
+          <h4>リスト</h4>
+          <ul className="hljs">
+            <li>- リスト 1</li>
+            <li>&nbsp;&nbsp;- リスト 1_1</li>
+            <li>- リスト 2</li>
+          </ul>
+          <ul className="hljs">
+            <li>1. 番号付きリスト 1</li>
+            <li>1. 番号付きリスト 2</li>
+          </ul>
+          <ul className="hljs">
+            <li>- [ ] タスク(チェックなし)</li>
+            <li>- [x] タスク(チェック付き)</li>
+          </ul>
+          <h4>引用</h4>
+          <ul className="hljs">
+            <li>> 複数行の引用文を</li>
+            <li>> 書くことができます</li>
+          </ul>
+          <ul className="hljs">
+            <li>>> 多重引用</li>
+            <li>>>> 多重引用</li>
+            <li>>>>> 多重引用</li>
+          </ul>
+          <h4>Table</h4>
+          <ul className="hljs text-center">
+            <li>|&nbsp;&nbsp;&nbsp;左寄せ&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;中央寄せ&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;右寄せ&nbsp;&nbsp;&nbsp;|</li>
+            <li>|:-----------|:----------:|-----------:|</li>
+            <li>|column 1&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;column 2&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;column 3|</li>
+            <li>|column 1&nbsp;&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;column 2&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;&nbsp;column 3|</li>
+          </ul>
+          <h4>Images</h4>
+          <p className="mb-1"><code> ![Alt文字列](URL)</code> で<span className="text-info">&lt;img&gt;</span>タグを挿入できます</p>
+          <ul className="hljs">
+            <li>![ex](https://example.com/images/a.png)</li>
+          </ul>
+
+          <hr />
+          <a href="/Sandbox" className="btn btn-info btn-block" target="_blank">
+            <i className="icon-share-alt"/> Sandbox を開く
+          </a>
+        </div>
+      </div>
+    );
+  }
+
+  renderCheatsheetModalButton() {
+    const showCheatsheetModal = () => {
+      this.setState({isCheatsheetModalShown: true});
+    };
+
+    const hideCheatsheetModal = () => {
+      this.setState({isCheatsheetModalShown: false});
+    };
+
+    return (
+      <React.Fragment>
+        <Modal className="modal-gfm-cheatsheet" show={this.state.isCheatsheetModalShown} onHide={() => { hideCheatsheetModal() }}>
+          <Modal.Header closeButton>
+            <Modal.Title><i className="icon-fw icon-question"/>Markdown Help</Modal.Title>
+          </Modal.Header>
+          <Modal.Body className="pt-1">
+            { this.renderCheatsheetModalBody() }
+          </Modal.Body>
+        </Modal>
+
+        <a className="gfm-cheatsheet-modal-link text-muted small" onClick={() => { showCheatsheetModal() }}>
+          <i className="icon-question" /> Markdown
+        </a>
+      </React.Fragment>
+    );
+  }
+
   render() {
     const mode = this.state.isGfmMode ? 'gfm' : undefined;
     const defaultEditorOptions = {
       theme: 'elegant',
       lineNumbers: true,
     };
+    const additionalClasses = Array.from(this.state.additionalClassSet).join(' ');
     const editorOptions = Object.assign(defaultEditorOptions, this.props.editorOptions || {});
 
+    const placeholder = this.state.isGfmMode ? 'Input with Markdown..' : 'Input with Plane Text..';
+
     return <React.Fragment>
       <ReactCodeMirror
         ref="cm"
-        className={this.state.additionalClass}
+        className={additionalClasses}
+        placeholder="search"
         editorDidMount={(editor) => {
           // add event handlers
           editor.on('paste', this.pasteHandler);
@@ -466,6 +676,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
           lineWrapping: true,
           autoRefresh: {force: true},   // force option is enabled by autorefresh.ext.js -- Yuki Takei
           autoCloseTags: true,
+          placeholder: placeholder,
           matchBrackets: true,
           matchTags: {bothTags: true},
           // folding
@@ -494,16 +705,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
             this.props.onScroll(data);
           }
         }}
-        onChange={(editor, data, value) => {
-          if (this.props.onChange != null) {
-            this.props.onChange(value);
-          }
-
-          // Emoji AutoComplete
-          if (this.state.isEnabledEmojiAutoComplete) {
-            this.emojiAutoCompleteHelper.showHint(editor);
-          }
-        }}
+        onChange={this.changeHandler}
         onDragEnter={(editor, event) => {
           if (this.props.onDragEnter != null) {
             this.props.onDragEnter(event);
@@ -513,6 +715,11 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
       { this.renderLoadingKeymapOverlay() }
 
+      <div className="overlay overlay-gfm-cheatsheet mt-1 p-3 pt-3">
+        { this.state.isSimpleCheatsheetShown && this.renderSimpleCheatsheet() }
+        { this.state.isCheatsheetModalButtonShown && this.renderCheatsheetModalButton() }
+      </div>
+
     </React.Fragment>;
   }
 

+ 1 - 1
resource/js/components/PageEditor/Editor.js

@@ -187,7 +187,7 @@ export default class Editor extends AbstractEditor {
 
   renderDropzoneOverlay() {
     return (
-      <div className="overlay">
+      <div className="overlay overlay-dropzone-active">
         {this.state.isUploading &&
           <span className="overlay-content">
             <div className="speeding-wheel d-inline-block"></div>

+ 159 - 27
resource/js/components/PageEditorByHackmd.jsx

@@ -1,6 +1,9 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import SplitButton from 'react-bootstrap/es/SplitButton';
+import MenuItem from 'react-bootstrap/es/MenuItem';
+
 import * as toastr from 'toastr';
 
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
@@ -11,12 +14,18 @@ export default class PageEditorByHackmd extends React.PureComponent {
     super(props);
 
     this.state = {
+      markdown: this.props.markdown,
+      isInitialized: false,
       isInitializing: false,
+      revisionId: this.props.revisionId,
       pageIdOnHackmd: this.props.pageIdOnHackmd,
+      hasDraftOnHackmd: this.props.hasDraftOnHackmd,
     };
 
     this.getHackmdUri = this.getHackmdUri.bind(this);
-    this.startIntegrationWithHackmd = this.startIntegrationWithHackmd.bind(this);
+    this.startToEdit = this.startToEdit.bind(this);
+    this.resumeToEdit = this.resumeToEdit.bind(this);
+    this.hackmdEditorChangeHandler = this.hackmdEditorChangeHandler.bind(this);
 
     this.apiErrorHandler = this.apiErrorHandler.bind(this);
   }
@@ -24,19 +33,46 @@ export default class PageEditorByHackmd extends React.PureComponent {
   componentWillMount() {
   }
 
-  getHackmdUri() {
-    const envVars = this.props.crowi.config.env;
-    return envVars.HACKMD_URI;
+  /**
+   * return markdown document of HackMD
+   * @return {Promise<string>}
+   */
+  getMarkdown() {
+    if (!this.state.isInitialized) {
+      return Promise.reject(new Error('HackmdEditor component has not initialized'));
+    }
+
+    return this.refs.hackmdEditor.getValue()
+      .then(document => {
+        this.setState({ markdown: document });
+        return document;
+      });
   }
 
-  syncToLatestRevision() {
+  setMarkdown(markdown, updateEditorValue = true) {
+    this.setState({ markdown });
+    if (this.state.isInitialized && updateEditorValue) {
+      this.refs.hackmdEditor.setValue(markdown);
+    }
+  }
 
+  /**
+   * update revisionId of state
+   * @param {string} revisionId
+   */
+  setRevisionId(revisionId) {
+    this.setState({revisionId});
+  }
+
+  getHackmdUri() {
+    const envVars = this.props.crowi.config.env;
+    return envVars.HACKMD_URI;
   }
 
   /**
    * Start integration with HackMD
    */
-  startIntegrationWithHackmd() {
+  startToEdit() {
     const hackmdUri = this.getHackmdUri();
 
     if (hackmdUri == null) {
@@ -44,18 +80,25 @@ export default class PageEditorByHackmd extends React.PureComponent {
       return;
     }
 
-    this.setState({isInitializing: true});
+    this.setState({
+      isInitialized: false,
+      isInitializing: true,
+    });
 
     const params = {
       pageId: this.props.pageId,
     };
-    this.props.crowi.apiPost('/hackmd/integrate', params)
+    this.props.crowi.apiPost('/hackmd.integrate', params)
       .then(res => {
         if (!res.ok) {
           throw new Error(res.error);
         }
 
-        this.setState({pageIdOnHackmd: res.pageIdOnHackmd});
+        this.setState({
+          isInitialized: true,
+          pageIdOnHackmd: res.pageIdOnHackmd,
+          revisionIdHackmdSynced: res.revisionIdHackmdSynced,
+        });
       })
       .catch(this.apiErrorHandler)
       .then(() => {
@@ -63,6 +106,48 @@ export default class PageEditorByHackmd extends React.PureComponent {
       });
   }
 
+  /**
+   * Start to edit w/o any api request
+   */
+  resumeToEdit() {
+    this.setState({isInitialized: true});
+  }
+
+  /**
+   * Reset draft
+   */
+  discardChanges() {
+    this.setState({hasDraftOnHackmd: false});
+  }
+
+  /**
+   * onChange event of HackmdEditor handler
+   */
+  hackmdEditorChangeHandler(body) {
+    const hackmdUri = this.getHackmdUri();
+
+    if (hackmdUri == null) {
+      // do nothing
+      return;
+    }
+
+    // do nothing if contents are same
+    if (this.props.markdown === body) {
+      return;
+    }
+
+    const params = {
+      pageId: this.props.pageId,
+    };
+    this.props.crowi.apiPost('/hackmd.saveOnHackmd', params)
+      .then(res => {
+        // do nothing
+      })
+      .catch(err => {
+        // do nothing
+      });
+  }
+
   apiErrorHandler(error) {
     toastr.error(error.message, 'Error occured', {
       closeButton: true,
@@ -77,30 +162,75 @@ export default class PageEditorByHackmd extends React.PureComponent {
   render() {
     const hackmdUri = this.getHackmdUri();
 
-    if (hackmdUri == null || this.state.pageIdOnHackmd == null) {
+    const isPageExistsOnHackmd = (this.state.pageIdOnHackmd != null);
+    const isRevisionMatch = (this.state.revisionId === this.props.revisionIdHackmdSynced);
+    const isResume = isPageExistsOnHackmd && isRevisionMatch && this.state.hasDraftOnHackmd;
+
+    if (this.state.isInitialized) {
       return (
-        <div className="hackmd-nopage d-flex justify-content-center align-items-center">
-          <div>
-            <p className="text-center">
-              <button className="btn btn-success btn-lg waves-effect waves-light" type="button"
-                  onClick={() => this.startIntegrationWithHackmd()} disabled={this.state.isInitializing}>
-                <span className="btn-label"><i className="fa fa-file-text-o"></i></span>
-                Start to edit with HackMD
-              </button>
-            </p>
-            <p className="text-center">Clone this page and start to edit with multiple peoples.</p>
+        <HackmdEditor
+          ref='hackmdEditor'
+          hackmdUri={hackmdUri}
+          pageIdOnHackmd={this.state.pageIdOnHackmd}
+          initializationMarkdown={isResume ? null : this.state.markdown}
+          onChange={this.hackmdEditorChangeHandler}
+          onSaveWithShortcut={(document) => {
+            this.props.onSaveWithShortcut(document);
+          }}
+        >
+        </HackmdEditor>
+      );
+    }
+
+    let content = undefined;
+    // HackMD is not setup
+    if (hackmdUri == null) {
+      content = (
+        <div>
+          <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is not set up.</p>
+        </div>
+      );
+    }
+    else if (isResume) {
+      const title = (
+        <React.Fragment>
+          <span className="btn-label"><i className="icon-control-end"></i></span>
+          Resume to edit with HackMD
+        </React.Fragment>);
+      content = (
+        <div>
+          <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
+          <div className="text-center hackmd-resume-button-container mb-3">
+            <SplitButton id='split-button-resume-hackmd' title={title} bsStyle="success" bsSize="large" className="btn-resume waves-effect waves-light" onClick={() => this.resumeToEdit()}>
+              <MenuItem className="text-center" onClick={() => this.discardChanges()}>
+                <i className="icon-control-rewind"></i> Discard changes
+              </MenuItem>
+            </SplitButton>
           </div>
+          <p className="text-center">Click to edit from the previous continuation.</p>
+        </div>
+      );
+    }
+    else {
+      content = (
+        <div>
+          <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
+          <div className="text-center hackmd-start-button-container mb-3">
+            <button className="btn btn-info btn-lg waves-effect waves-light" type="button"
+                onClick={() => this.startToEdit()} disabled={this.state.isInitializing}>
+              <span className="btn-label"><i className="icon-paper-plane"></i></span>
+              Start to edit with HackMD
+            </button>
+          </div>
+          <p className="text-center">Click to clone page content and start to edit.</p>
         </div>
       );
     }
 
     return (
-      <HackmdEditor
-        markdown={this.props.markdown}
-        hackmdUri={hackmdUri}
-        pageIdOnHackmd={this.state.pageIdOnHackmd}
-      >
-      </HackmdEditor>
+      <div className="hackmd-preinit d-flex justify-content-center align-items-center">
+        {content}
+      </div>
     );
   }
 }
@@ -108,8 +238,10 @@ export default class PageEditorByHackmd extends React.PureComponent {
 PageEditorByHackmd.propTypes = {
   crowi: PropTypes.object.isRequired,
   markdown: PropTypes.string.isRequired,
+  onSaveWithShortcut: PropTypes.func.isRequired,
   pageId: PropTypes.string,
   revisionId: PropTypes.string,
-  revisionIdHackmdSynced: PropTypes.string,
   pageIdOnHackmd: PropTypes.string,
+  revisionIdHackmdSynced: PropTypes.string,
+  hasDraftOnHackmd: PropTypes.bool,
 };

+ 61 - 12
resource/js/components/PageEditorByHackmd/HackmdEditor.jsx

@@ -1,6 +1,9 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import Penpal from 'penpal';
+// Penpal.debug = true;
+
 export default class HackmdEditor extends React.PureComponent {
 
   constructor(props) {
@@ -9,35 +12,81 @@ export default class HackmdEditor extends React.PureComponent {
     this.state = {
     };
 
-    this.loadHandler = this.loadHandler.bind(this);
+    this.hackmd = null;
+
+    this.initHackmdWithPenpal = this.initHackmdWithPenpal.bind(this);
+
+    this.notifyBodyChangesHandler = this.notifyBodyChangesHandler.bind(this);
+    this.saveWithShortcutHandler = this.saveWithShortcutHandler.bind(this);
   }
 
-  componentWillMount() {
+  componentDidMount() {
+    // append iframe with penpal
+    this.initHackmdWithPenpal();
   }
 
-  syncToLatestRevision() {
+  initHackmdWithPenpal() {
+    const _this = this;   // for in methods scope
 
+    const url = `${this.props.hackmdUri}/${this.props.pageIdOnHackmd}?both`;
+
+    const connection = Penpal.connectToChild({
+      url,
+      appendTo: this.refs.iframeContainer,
+      methods: {  // expose methods to HackMD
+        notifyBodyChanges(document) {
+          _this.notifyBodyChangesHandler(document);
+        },
+        saveWithShortcut(document) {
+          _this.saveWithShortcutHandler(document);
+        }
+      },
+    });
+    connection.promise.then(child => {
+      this.hackmd = child;
+      if (this.props.initializationMarkdown != null) {
+        child.setValueOnInit(this.props.initializationMarkdown);
+      }
+    });
   }
 
-  loadHandler() {
+  /**
+   * return markdown document of HackMD
+   * @return {Promise<string>}
+   */
+  getValue() {
+    return this.hackmd.getValue();
+  }
+
+  setValue(newValue) {
+    this.hackmd.setValue(newValue);
+  }
+
+  notifyBodyChangesHandler(body) {
+    // dispatch onChange()
+    if (this.props.onChange != null) {
+      this.props.onChange(body);
+    }
+  }
 
+  saveWithShortcutHandler(document) {
+    if (this.props.onSaveWithShortcut != null) {
+      this.props.onSaveWithShortcut(document);
+    }
   }
 
   render() {
-    const src = `${this.props.hackmdUri}/${this.props.pageIdOnHackmd}`;
     return (
-      <iframe id='iframe-hackmd'
-        ref='iframe'
-        src={src}
-        onLoad={this.loadHandler}
-      >
-      </iframe>
+      // will be rendered in componentDidMount
+      <div id='iframe-hackmd-container' ref='iframeContainer'></div>
     );
   }
 }
 
 HackmdEditor.propTypes = {
-  markdown: PropTypes.string.isRequired,
   hackmdUri: PropTypes.string.isRequired,
   pageIdOnHackmd: PropTypes.string.isRequired,
+  initializationMarkdown: PropTypes.string,
+  onChange: PropTypes.func,
+  onSaveWithShortcut: PropTypes.func,
 };

+ 91 - 0
resource/js/components/SavePageControls.jsx

@@ -0,0 +1,91 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { translate } from 'react-i18next';
+
+import SlackNotification from './SlackNotification';
+import GrantSelector from './SavePageControls/GrantSelector';
+
+class SavePageControls extends React.PureComponent {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      pageId: this.props.pageId,
+    };
+
+    this.getCurrentOptionsToSave = this.getCurrentOptionsToSave.bind(this);
+    this.submit = this.submit.bind(this);
+  }
+
+  componentWillMount() {
+  }
+
+  getCurrentOptionsToSave() {
+    const slackNotificationOptions = this.refs.slackNotification.getCurrentOptionsToSave();
+    const grantSelectorOptions = this.refs.grantSelector.getCurrentOptionsToSave();
+    return Object.assign(slackNotificationOptions, grantSelectorOptions);
+  }
+
+  /**
+   * update pageId of state
+   * @param {string} pageId
+   */
+  setPageId(pageId) {
+    this.setState({pageId});
+  }
+
+  submit() {
+    this.props.onSubmit();
+  }
+
+  render() {
+    const { t } = this.props;
+
+    const label = this.state.pageId == null ? t('Create') : t('Update');
+
+    return (
+      <div className="d-flex align-items-center form-inline">
+        <div className="mr-2">
+          <SlackNotification
+              ref='slackNotification'
+              crowi={this.props.crowi}
+              pageId={this.props.pageId}
+              pagePath={this.props.pagePath}
+              isSlackEnabled={false}
+              slackChannels={this.props.slackChannels} />
+        </div>
+
+        <div className="mr-2">
+          <GrantSelector crowi={this.props.crowi}
+              ref={(elem) => {
+                if (this.refs.grantSelector == null) {
+                  this.refs.grantSelector = elem.getWrappedInstance();
+                }
+              }}
+              grant={this.props.grant}
+              grantGroupId={this.props.grantGroupId}
+              grantGroupName={this.props.grantGroupName} />
+        </div>
+
+        <button className="btn btn-primary btn-submit" onClick={this.submit}>{label}</button>
+      </div>
+    );
+  }
+}
+
+SavePageControls.propTypes = {
+  t: PropTypes.func.isRequired,               // i18next
+  crowi: PropTypes.object.isRequired,
+  onSubmit: PropTypes.func.isRequired,
+  pageId: PropTypes.string,
+  // for SlackNotification
+  pagePath: PropTypes.string,
+  slackChannels: PropTypes.string,
+  // for GrantSelector
+  grant: PropTypes.number,
+  grantGroupId: PropTypes.string,
+  grantGroupName: PropTypes.string,
+};
+
+export default translate()(SavePageControls);

+ 47 - 62
resource/js/components/PageEditor/GrantSelector.js → resource/js/components/SavePageControls/GrantSelector.jsx

@@ -23,29 +23,30 @@ class GrantSelector extends React.Component {
     super(props);
 
     this.availableGrants = [
-      { pageGrant: 1, iconClass: 'icon-people', styleClass: '', label: 'Public' },
-      { pageGrant: 2, iconClass: 'icon-link', styleClass: 'text-info', label: 'Anyone with the link' },
-      // { pageGrant: 3, iconClass: '', label: 'Specified users only' },
-      { pageGrant: 4, iconClass: 'icon-lock', styleClass: 'text-danger', label: 'Just me' },
-      { pageGrant: 5, iconClass: 'icon-options', styleClass: '', label: 'Only inside the group' },  // appeared only one of these 'pageGrant: 5'
-      { pageGrant: 5, iconClass: 'icon-options', styleClass: '', label: 'Reselect the group' },     // appeared only one of these 'pageGrant: 5'
+      { grant: 1, iconClass: 'icon-people', styleClass: '', label: 'Public' },
+      { grant: 2, iconClass: 'icon-link', styleClass: 'text-info', label: 'Anyone with the link' },
+      // { grant: 3, iconClass: '', label: 'Specified users only' },
+      { grant: 4, iconClass: 'icon-lock', styleClass: 'text-danger', label: 'Just me' },
+      { grant: 5, iconClass: 'icon-options', styleClass: '', label: 'Only inside the group' },  // appeared only one of these 'grant: 5'
+      { grant: 5, iconClass: 'icon-options', styleClass: '', label: 'Reselect the group' },     // appeared only one of these 'grant: 5'
     ];
 
     this.state = {
-      pageGrant: this.props.pageGrant || 1,  // default: 1
+      grant: this.props.grant || 1,  // default: 1
       userRelatedGroups: [],
       isSelectGroupModalShown: false,
     };
-    if (this.props.pageGrantGroupId !== '') {
-      this.state.pageGrantGroup = {
-        _id: this.props.pageGrantGroupId,
-        name: this.props.pageGrantGroupName
+    if (this.props.grantGroupId !== '') {
+      this.state.grantGroup = {
+        _id: this.props.grantGroupId,
+        name: this.props.grantGroupName
       };
     }
 
     // retrieve xss library from window
     this.xss = window.xss;
 
+    this.getCurrentOptionsToSave = this.getCurrentOptionsToSave.bind(this);
     this.showSelectGroupModal = this.showSelectGroupModal.bind(this);
     this.hideSelectGroupModal = this.hideSelectGroupModal.bind(this);
 
@@ -60,20 +61,30 @@ class GrantSelector extends React.Component {
      * set SPECIFIED_GROUP_VALUE to grant selector
      *  cz: bootstrap-select input element has the defferent state to React component
      */
-    if (this.state.pageGrantGroup != null) {
+    if (this.state.grantGroup != null) {
       this.grantSelectorInputEl.value = SPECIFIED_GROUP_VALUE;
     }
 
     // refresh bootstrap-select
     // see https://silviomoreto.github.io/bootstrap-select/methods/#selectpickerrefresh
-    $('.page-grant-selector.selectpicker').selectpicker('refresh');
+    $('.grant-selector .selectpicker').selectpicker('refresh');
     //// DIRTY HACK -- 2018.05.25 Yuki Takei
     // set group name to the bootstrap-select options
     //  cz: .selectpicker('refresh') doesn't replace data-content
-    $('.page-grant-selector .group-name').text(this.getGroupName());
+    $('.grant-selector .group-name').text(this.getGroupName());
 
   }
 
+  getCurrentOptionsToSave() {
+    const options = {
+      grant: this.state.grant
+    };
+    if (this.state.grantGroup != null) {
+      options.grantUserGroupId = this.state.grantGroup._id;
+    }
+    return options;
+  }
+
   showSelectGroupModal() {
     this.retrieveUserGroupRelations();
     this.setState({ isSelectGroupModalShown: true });
@@ -83,8 +94,8 @@ class GrantSelector extends React.Component {
   }
 
   getGroupName() {
-    const pageGrantGroup = this.state.pageGrantGroup;
-    return pageGrantGroup ? this.xss.process(pageGrantGroup.name) : '';
+    const grantGroup = this.state.grantGroup;
+    return grantGroup ? this.xss.process(grantGroup.name) : '';
   }
 
   /**
@@ -104,53 +115,31 @@ class GrantSelector extends React.Component {
   }
 
   /**
-   * change event handler for pageGrant selector
+   * change event handler for grant selector
    */
   changeGrantHandler() {
-    const pageGrant = +this.grantSelectorInputEl.value;
+    const grant = +this.grantSelectorInputEl.value;
 
     // select group
-    if (pageGrant === 5) {
+    if (grant === 5) {
       this.showSelectGroupModal();
       /*
        * reset grant selector to state
        */
-      this.grantSelectorInputEl.value = this.state.pageGrant;
+      this.grantSelectorInputEl.value = this.state.grant;
       return;
     }
 
-    this.setState({ pageGrant, pageGrantGroup: null });
-    // dispatch event
-    this.dispatchOnChangePageGrant(pageGrant);
-    this.dispatchOnDeterminePageGrantGroup(null);
+    this.setState({ grant, grantGroup: null });
   }
 
-  groupListItemClickHandler(pageGrantGroup) {
-    this.setState({ pageGrant: 5, pageGrantGroup });
-
-    // dispatch event
-    this.dispatchOnChangePageGrant(5);
-    this.dispatchOnDeterminePageGrantGroup(pageGrantGroup);
+  groupListItemClickHandler(grantGroup) {
+    this.setState({ grant: 5, grantGroup });
 
     // hide modal
     this.hideSelectGroupModal();
   }
 
-  dispatchOnChangePageGrant(pageGrant) {
-    if (this.props.onChangePageGrant != null) {
-      this.props.onChangePageGrant(pageGrant);
-    }
-  }
-
-  dispatchOnDeterminePageGrantGroup(pageGrantGroup) {
-    if (this.props.onDeterminePageGrantGroupId != null) {
-      this.props.onDeterminePageGrantGroupId(pageGrantGroup ? pageGrantGroup._id : '');
-    }
-    if (this.props.onDeterminePageGrantGroupName != null) {
-      this.props.onDeterminePageGrantGroupName(pageGrantGroup ? pageGrantGroup.name : '');
-    }
-  }
-
   /**
    * Render grant selector DOM.
    * @returns
@@ -160,14 +149,14 @@ class GrantSelector extends React.Component {
     const { t } = this.props;
 
     let index = 0;
-    let selectedValue = this.state.pageGrant;
-    const grantElems = this.availableGrants.map((grant) => {
-      const dataContent = `<i class="icon icon-fw ${grant.iconClass} ${grant.styleClass}"></i> <span class="${grant.styleClass}">${t(grant.label)}</span>`;
-      return <option key={index++} value={grant.pageGrant} data-content={dataContent}>{t(grant.label)}</option>;
+    let selectedValue = this.state.grant;
+    const grantElems = this.availableGrants.map((opt) => {
+      const dataContent = `<i class="icon icon-fw ${opt.iconClass} ${opt.styleClass}"></i> <span class="${opt.styleClass}">${t(opt.label)}</span>`;
+      return <option key={index++} value={opt.grant} data-content={dataContent}>{t(opt.label)}</option>;
     });
 
-    const pageGrantGroup = this.state.pageGrantGroup;
-    if (pageGrantGroup != null) {
+    const grantGroup = this.state.grantGroup;
+    if (grantGroup != null) {
       selectedValue = SPECIFIED_GROUP_VALUE;
       // DIRTY HACK -- 2018.05.25 Yuki Takei
       // remove 'Only inside the group' item
@@ -188,7 +177,7 @@ class GrantSelector extends React.Component {
 
     // add specified group option
     grantElems.push(
-      <option ref="specifiedGroupOption" key="specifiedGroupKey" value={SPECIFIED_GROUP_VALUE} style={{ display: pageGrantGroup ? 'inherit' : 'none' }}
+      <option ref="specifiedGroupOption" key="specifiedGroupKey" value={SPECIFIED_GROUP_VALUE} style={{ display: grantGroup ? 'inherit' : 'none' }}
           data-content={`<i class="icon icon-fw icon-organization text-success"></i> <span class="group-name text-success">${this.getGroupName()}</span>`}>
         {this.getGroupName()}
       </option>
@@ -196,8 +185,8 @@ class GrantSelector extends React.Component {
 
     const bsClassName = 'form-control-dummy'; // set form-control* to shrink width
     return (
-      <FormGroup className="m-b-0">
-        <FormControl componentClass="select" placeholder="select" defaultValue={selectedValue} bsClass={bsClassName} className="btn-group-sm page-grant-selector selectpicker"
+      <FormGroup className="grant-selector m-b-0">
+        <FormControl componentClass="select" placeholder="select" defaultValue={selectedValue} bsClass={bsClassName} className="btn-group-sm selectpicker"
           onChange={this.changeGrantHandler}
           inputRef={ el => this.grantSelectorInputEl=el }>
 
@@ -252,7 +241,7 @@ class GrantSelector extends React.Component {
 
   render() {
     return <React.Fragment>
-      <div className="m-r-5">{this.renderGrantSelector()}</div>
+      {this.renderGrantSelector()}
       {this.renderSelectGroupModal()}
     </React.Fragment>;
   }
@@ -261,13 +250,9 @@ class GrantSelector extends React.Component {
 GrantSelector.propTypes = {
   t: PropTypes.func.isRequired,               // i18next
   crowi: PropTypes.object.isRequired,
-  isGroupModalShown: PropTypes.bool,
-  pageGrant: PropTypes.number,
-  pageGrantGroupId: PropTypes.string,
-  pageGrantGroupName: PropTypes.string,
-  onChangePageGrant: PropTypes.func,
-  onDeterminePageGrantGroupId: PropTypes.func,
-  onDeterminePageGrantGroupName: PropTypes.func,
+  grant: PropTypes.number,
+  grantGroupId: PropTypes.string,
+  grantGroupName: PropTypes.string,
 };
 
 export default translate()(GrantSelector);

+ 2 - 2
resource/js/components/SearchPage/DeletePageListModal.js

@@ -43,8 +43,8 @@ export default class DeletePageListModal extends React.Component {
         <Modal.Footer>
           <div className="d-flex justify-content-between">
             <span className="text-danger">{this.props.errorMessage}</span>
-            <span className="d-flex">
-              <Checkbox onClick={this.props.toggleDeleteCompletely}>Delete completely</Checkbox>
+            <span className="d-flex align-items-center">
+              <Checkbox className="text-danger" onClick={this.props.toggleDeleteCompletely} inline={true}>Delete completely</Checkbox>
               <span className="m-l-10">
                 <Button onClick={this.props.confirmedToDelete}><i className="icon-trash"></i>Delete</Button>
               </span>

+ 33 - 23
resource/js/components/SearchPage/SearchResult.js

@@ -1,5 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import * as toastr from 'toastr';
 
 import Page from '../PageList/Page';
 import SearchResultList from './SearchResultList';
@@ -106,32 +107,41 @@ export default class SearchResult extends React.Component {
    * @memberof SearchResult
    */
   deleteSelectedPages() {
-    let isDeleted = true;
     let deleteCompletely = this.state.isDeleteCompletely;
-    Array.from(this.state.selectedPages).map((page) => {
-      const pageId = page._id;
-      const revisionId = page.revision._id;
-      this.props.crowi.apiPost('/pages.remove',
-        {page_id: pageId, revision_id: revisionId, completely: deleteCompletely})
-      .then(res => {
-        if (res.ok) {
-          this.state.selectedPages.delete(page);
-        }
-        else {
-          isDeleted = false;
-        }
-      }).catch(err => {
-        /* eslint-disable no-console */
-        console.log(err.message);
-        /* eslint-enable */
-        isDeleted = false;
-        this.setState({errorMessageForDeleting: err.message});
+    Promise.all(Array.from(this.state.selectedPages).map((page) => {
+      return new Promise((resolve, reject) => {
+        const pageId = page._id;
+        const revisionId = page.revision._id;
+        this.props.crowi.apiPost('/pages.remove', {page_id: pageId, revision_id: revisionId, completely: deleteCompletely})
+          .then(res => {
+            if (res.ok) {
+              this.state.selectedPages.delete(page);
+              return resolve();
+            }
+            else {
+              return reject();
+            }
+          })
+          .catch(err => {
+            console.log(err.message);   // eslint-disable-line no-console
+            this.setState({errorMessageForDeleting: err.message});
+            return reject();
+          });
       });
-    });
-
-    if ( isDeleted ) {
+    }))
+    .then(() => {
       window.location.reload();
-    }
+    })
+    .catch(err => {
+      toastr.error(err, 'Error occured', {
+        closeButton: true,
+        progressBar: true,
+        newestOnTop: false,
+        showDuration: '100',
+        hideDuration: '100',
+        timeOut: '3000',
+      });
+    });
   }
 
   /**

+ 22 - 1
resource/js/components/SearchTypeahead.js

@@ -27,7 +27,9 @@ export default class SearchTypeahead extends React.Component {
 
     this.search = this.search.bind(this);
     this.onInputChange = this.onInputChange.bind(this);
+    this.onKeyDown = this.onKeyDown.bind(this);
     this.onChange = this.onChange.bind(this);
+    this.dispatchSubmit = this.dispatchSubmit.bind(this);
     this.getRestoreFormButton = this.getRestoreFormButton.bind(this);
     this.renderMenuItemChildren = this.renderMenuItemChildren.bind(this);
     this.restoreInitialData = this.restoreInitialData.bind(this);
@@ -91,6 +93,15 @@ export default class SearchTypeahead extends React.Component {
 
   onInputChange(text) {
     this.setState({input: text});
+    if (text === '') {
+      this.setState({pages: []});
+    }
+  }
+
+  onKeyDown(event) {
+    if (event.keyCode === 13) {
+      this.dispatchSubmit();
+    }
   }
 
   onChange(selected) {
@@ -102,6 +113,12 @@ export default class SearchTypeahead extends React.Component {
     }
   }
 
+  dispatchSubmit() {
+    if (this.props.onSubmit != null) {
+      this.props.onSubmit(this.state.keyword);
+    }
+  }
+
   renderMenuItemChildren(option, props, index) {
     const page = option;
     return (
@@ -151,16 +168,18 @@ export default class SearchTypeahead extends React.Component {
           inputProps={{name: 'q', autoComplete: 'off'}}
           isLoading={this.state.isLoading}
           labelKey="path"
-          minLength={2}
+          minLength={0}
           options={this.state.pages} // Search result (Some page names)
           emptyLabel={this.emptyLabel ? this.emptyLabel : emptyLabel}
           align='left'
           submitFormOnEnter={true}
           onSearch={this.search}
           onInputChange={this.onInputChange}
+          onKeyDown={this.onKeyDown}
           renderMenuItemChildren={this.renderMenuItemChildren}
           caseSensitive={false}
           defaultSelected={defaultSelected}
+          promptText={this.props.promptText}
         />
         {restoreFormButton}
       </div>
@@ -176,9 +195,11 @@ SearchTypeahead.propTypes = {
   onSearchSuccess: PropTypes.func,
   onSearchError:   PropTypes.func,
   onChange:        PropTypes.func,
+  onSubmit:        PropTypes.func,
   emptyLabel:      PropTypes.string,
   placeholder:     PropTypes.string,
   keywordOnInit:   PropTypes.string,
+  promptText:      PropTypes.object,
 };
 
 /**

+ 20 - 10
resource/js/components/SlackNotification.js → resource/js/components/SlackNotification.jsx

@@ -31,29 +31,36 @@ export default class SlackNotification extends React.Component {
     });
   }
 
+  getCurrentOptionsToSave() {
+    return Object.assign({}, this.state);
+  }
+
   updateState(value) {
     this.setState({slackChannels: value});
-    this.props.onChannelChange(value);
+    // dispatch event
+    if (this.props.onChannelChange != null) {
+      this.props.onChannelChange(value);
+    }
   }
 
   updateStateCheckbox(event) {
     const value = event.target.checked;
     this.setState({isSlackEnabled: value});
-    this.props.onSlackOnChange(value);
+    // dispatch event
+    if (this.props.onEnabledFlagChange != null) {
+      this.props.onEnabledFlagChange(value);
+    }
   }
 
   render() {
-    const formNameSlackOn = this.props.formName && this.props.formName + '[notify][slack][on]';
-    const formNameChannels = this.props.formName && this.props.formName + '[notify][slack][channel]';
-
     return (
       <div className="input-group input-group-sm input-group-slack extended-setting">
         <label className="input-group-addon">
           <img id="slack-mark-white" src="/images/icons/slack/mark-monochrome_white.svg" width="18" height="18"/>
           <img id="slack-mark-black" src="/images/icons/slack/mark-monochrome_black.svg" width="18" height="18"/>
-          <input type="checkbox" name={formNameSlackOn} value="1" checked={this.state.isSlackEnabled} onChange={this.updateStateCheckbox}/>
+          <input type="checkbox" value="1" checked={this.state.isSlackEnabled} onChange={this.updateStateCheckbox}/>
         </label>
-        <input className="form-control" type="text" name={formNameChannels} value={this.state.slackChannels} placeholder="slack channel name"
+        <input className="form-control" type="text" value={this.state.slackChannels} placeholder="slack channel name"
           data-toggle="popover"
           title="Slack通知"
           data-content="通知するにはチェックを入れてください。カンマ区切りで複数チャンネルに通知することができます。"
@@ -70,9 +77,12 @@ SlackNotification.propTypes = {
   crowi: PropTypes.object.isRequired,
   pageId: PropTypes.string,
   pagePath: PropTypes.string,
-  onChannelChange: PropTypes.func,
-  onSlackOnChange: PropTypes.func,
   isSlackEnabled: PropTypes.bool,
   slackChannels: PropTypes.string,
-  formName: PropTypes.string,
+  onChannelChange: PropTypes.func,
+  onEnabledFlagChange: PropTypes.func,
+};
+
+SlackNotification.defaultProps = {
+  slackChannels: '',
 };

+ 145 - 0
resource/js/hackmd-agent.js

@@ -0,0 +1,145 @@
+/**
+ * GROWI agent for HackMD
+ *
+ * This file will be transpiled as a single JS
+ *  and should be load from HackMD head via 'lib/routes/hackmd.js' route
+ *
+ * USAGE:
+ *  <script src="${hostname of GROWI}/_hackmd/load-agent"></script>
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ */
+import Penpal from 'penpal';
+// Penpal.debug = true;
+
+import { debounce } from 'throttle-debounce';
+
+/* eslint-disable no-console  */
+
+const allowedOrigin = '{{origin}}';         // will be replaced by swig
+
+
+/**
+ * return the value of CodeMirror
+ */
+function getValueOfCodemirror() {
+  // get CodeMirror instance
+  const editor = window.editor;
+  return editor.doc.getValue();
+}
+
+/**
+ * set the specified document to CodeMirror
+ * @param {string} value
+ */
+function setValueToCodemirror(value) {
+  // get CodeMirror instance
+  const editor = window.editor;
+  editor.doc.setValue(value);
+}
+
+/**
+ * set the specified document to CodeMirror on window loaded
+ * @param {string} value
+ */
+function setValueToCodemirrorOnInit(newValue) {
+  if (window.cmClient != null) {
+    setValueToCodemirror(newValue);
+    return;
+  }
+  else {
+    const intervalId = setInterval(() => {
+      if (window.cmClient != null) {
+        clearInterval(intervalId);
+        setValueToCodemirror(newValue);
+      }
+    }, 250);
+  }
+}
+
+/**
+ * postMessage to GROWI to notify body changes
+ * @param {string} body
+ */
+function postParentToNotifyBodyChanges(body) {
+  window.growi.notifyBodyChanges(body);
+}
+// generate debounced function
+const debouncedPostParentToNotifyBodyChanges = debounce(1500, postParentToNotifyBodyChanges);
+
+/**
+ * postMessage to GROWI to save with shortcut
+ * @param {string} document
+ */
+function postParentToSaveWithShortcut(document) {
+  window.growi.saveWithShortcut(document);
+}
+
+function addEventListenersToCodemirror() {
+  // get CodeMirror instance
+  const codemirror = window.CodeMirror;
+  // get CodeMirror editor instance
+  const editor = window.editor;
+
+  // e.g. 404 not found
+  if (codemirror == null || editor == null) {
+    return;
+  }
+
+  //// change event
+  editor.on('change', (cm, change) => {
+    debouncedPostParentToNotifyBodyChanges(cm.doc.getValue());
+  });
+
+  //// save event
+  // Reset save commands and Cmd-S/Ctrl-S shortcuts that initialized by HackMD
+  codemirror.commands.save = function(cm) {
+    postParentToSaveWithShortcut(cm.doc.getValue());
+  };
+  delete editor.options.extraKeys['Cmd-S'];
+  delete editor.options.extraKeys['Ctrl-S'];
+}
+
+function connectToParentWithPenpal() {
+  const connection = Penpal.connectToParent({
+    parentOrigin: allowedOrigin,
+    // Methods child is exposing to parent
+    methods: {
+      getValue() {
+        return getValueOfCodemirror();
+      },
+      setValue(newValue) {
+        setValueToCodemirror(newValue);
+      },
+      setValueOnInit(newValue) {
+        setValueToCodemirrorOnInit(newValue);
+      }
+    }
+  });
+  connection.promise.then(parent => {
+    window.growi = parent;
+  });
+}
+
+/**
+ * main
+ */
+(function() {
+  // check HackMD is in iframe
+  if (window === window.parent) {
+    console.log('[GROWI] Loading agent for HackMD is not processed because currently not in iframe');
+    return;
+  }
+
+  console.log('[HackMD] Loading GROWI agent for HackMD...');
+
+  window.addEventListener('load', (event) => {
+    console.log('loaded');
+    addEventListenersToCodemirror();
+  });
+
+  connectToParentWithPenpal();
+
+  console.log('[HackMD] GROWI agent for HackMD has successfully loaded.');
+}());
+

+ 42 - 0
resource/js/hackmd-styles.js

@@ -0,0 +1,42 @@
+/**
+ * GROWI styles loader for HackMD
+ *
+ * This file will be transpiled as a single JS
+ *  and should be load from HackMD head via 'lib/routes/hackmd.js' route
+ *
+ * USAGE:
+ *  <script src="${hostname of GROWI}/_hackmd/load-styles"></script>
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ */
+
+/* eslint-disable no-console  */
+
+const styles = '{{styles}}';         // will be replaced by swig
+
+/**
+ * Insert link tag to load style file
+ */
+function insertStyle() {
+  const element = document.createElement('style');
+  element.type = 'text/css';
+  element.appendChild(document.createTextNode(styles));
+  document.getElementsByTagName('head')[0].appendChild(element);
+}
+
+/**
+ * main
+ */
+(function() {
+  // check HackMD is in iframe
+  if (window === window.parent) {
+    console.log('[GROWI] Loading styles for HackMD is not processed because currently not in iframe');
+    return;
+  }
+
+  console.log('[HackMD] Loading GROWI styles for HackMD...');
+
+  insertStyle();
+
+  console.log('[HackMD] GROWI styles for HackMD has successfully loaded.');
+}());

+ 1 - 0
resource/js/i18n.js

@@ -36,6 +36,7 @@ export default (userlang) => {
       // react i18next special options (optional)
       react: {
         wait: false,
+        withRef: true,
         bindI18n: 'languageChanged loaded',
         bindStore: 'added removed',
         nsMode: 'default'

+ 21 - 9
resource/js/legacy/crowi-admin.js

@@ -1,6 +1,10 @@
-require('bootstrap-select');
 require('./thirdparty-js/jQuery.style.switcher');
 
+// see https://github.com/abpetkov/switchery/issues/120
+// see https://github.com/abpetkov/switchery/issues/120#issuecomment-286337221
+require('./thirdparty-js/switchery/switchery');
+require('./thirdparty-js/switchery/switchery.css');
+
 $(function() {
   $('#slackNotificationForm').on('submit', function(e) {
     $.post('/_api/admin/notification.add', $(this).serialize(), function(res) {
@@ -26,9 +30,9 @@ $(function() {
   $('#createdUserModal').modal('show');
 
   $('#admin-password-reset-modal').on('show.bs.modal', function(button) {
-    var data = $(button.relatedTarget);
-    var userId = data.data('user-id');
-    var email = data.data('user-email');
+    const data = $(button.relatedTarget);
+    const userId = data.data('user-id');
+    const email = data.data('user-email');
 
     $('#admin-password-reset-user').text(email);
     $('#admin-users-reset-password input[name=user_id]').val(userId);
@@ -55,9 +59,9 @@ $(function() {
   });
 
   $('#admin-delete-user-group-modal').on('show.bs.modal', function(button) {
-    var data = $(button.relatedTarget);
-    var userGroupId = data.data('user-group-id');
-    var userGroupName = data.data('user-group-name');
+    const data = $(button.relatedTarget);
+    const userGroupId = data.data('user-group-id');
+    const userGroupName = data.data('user-group-name');
 
     $('#admin-delete-user-group-name').text(userGroupName);
     $('#admin-user-groups-delete input[name=user_group_id]').val(userGroupId);
@@ -72,8 +76,8 @@ $(function() {
 
 
   $('#pictureUploadForm input[name=userGroupPicture]').on('change', function() {
-    var $form = $('#pictureUploadForm');
-    var fd = new FormData($form[0]);
+    const $form = $('#pictureUploadForm');
+    const fd = new FormData($form[0]);
     if ($(this).val() == '') {
       return false;
     }
@@ -105,6 +109,14 @@ $(function() {
 
   // style switcher
   $('#styleOptions').styleSwitcher();
+
+  // switchery
+  const elems = Array.prototype.slice.call(document.querySelectorAll('.js-switch'));
+  elems.forEach(function(elem) {
+    const color = elem.dataset.color;
+    const size = elem.dataset.size;
+    new Switchery(elem, { color, size });   // eslint-disable-line no-undef
+  });
 });
 
 

+ 11 - 605
resource/js/legacy/crowi-form.js

@@ -1,608 +1,14 @@
-var pageId = $('#content-main').data('page-id');
-var pagePath= $('#content-main').data('path');
+// const pagePath= $('#content-main').data('path');
 
-require('bootstrap-select');
+// /**
+//  * DOM ready
+//  */
+// $(function() {
 
-// for new page
-if (!pageId) {
-  if (!pageId && pagePath.match(/(20\d{4}|20\d{6}|20\d{2}_\d{1,2}|20\d{2}_\d{1,2}_\d{1,2})/)) {
-    $('#page-warning-modal').modal('show');
-  }
-}
+//   $('#page-form').on('submit', function(e) {
+//     // avoid message
+//     // isFormChanged = false;
+//     window.crowi.clearDraft(pagePath);
+//   });
 
-$('a[data-toggle="tab"][href="#edit"]').on('show.bs.tab', function() {
-  $('body').addClass('on-edit');
-  $('body').addClass('builtin-editor');
-});
-
-$('a[data-toggle="tab"][href="#edit"]').on('hide.bs.tab', function() {
-  $('body').removeClass('on-edit');
-  $('body').removeClass('builtin-editor');
-});
-$('a[data-toggle="tab"][href="#hackmd"]').on('show.bs.tab', function() {
-  $('body').addClass('on-edit');
-  $('body').addClass('hackmd');
-});
-
-$('a[data-toggle="tab"][href="#hackmd"]').on('hide.bs.tab', function() {
-  $('body').removeClass('on-edit');
-  $('body').removeClass('hackmd');
-});
-
-/**
- * DOM ready
- */
-$(function() {
-  /*
-   * DUPRECATED CODES
-   * using PageEditor React Component -- 2017.01.06 Yuki Takei
-   *
-
-  // preview watch
-  var originalContent = $('#form-body').val();
-
-  // restore draft
-  // とりあえず、originalContent がない場合のみ復元する。(それ以外の場合は後で考える)
-  var draft = crowi.findDraft(pagePath);
-  var originalRevision = $('#page-form [name="pageForm[currentRevision]"]').val();
-  if (!originalRevision && draft) {
-    // TODO
-    $('#form-body').val(draft)
-  }
-
-  var prevContent = originalContent;
-
-  function renderPreview() {
-    var markdown = $('#form-body').val();
-    var dom = $('#preview-body');
-
-    // create context object
-    var context = {
-      markdown,
-      dom,
-      currentPagePath: decodeURIComponent(location.pathname)
-    };
-
-    crowi.interceptorManager.process('preRenderPreview', context)
-      .then(() => crowi.interceptorManager.process('prePreProcess', context))
-      .then(() => {
-        context.markdown = crowiRenderer.preProcess(context.markdown);
-      })
-      .then(() => crowi.interceptorManager.process('postPreProcess', context))
-      .then(() => {
-        var parsedHTML = crowiRenderer.render(context.markdown, context.dom);
-        context['parsedHTML'] = parsedHTML;
-      })
-      .then(() => crowi.interceptorManager.process('postRenderPreview', context))
-      .then(() => crowi.interceptorManager.process('preRenderPreviewHtml', context))
-      // render HTML with jQuery
-      .then(() => {
-        $('#preview-body').html(context.parsedHTML);
-        Promise.resolve($('#preview-body'));
-      })
-      // process interceptors for post rendering
-      .then((bodyElement) => {
-        context = Object.assign(context, {bodyElement})
-        return crowi.interceptorManager.process('postRenderPreviewHtml', context);
-      });
-  }
-
-  // for initialize preview
-  renderPreview();
-  var watchTimer = setInterval(function() {
-    var content = $('#form-body').val();
-    if (prevContent != content) {
-
-      renderPreview();
-      prevContent = content;
-    }
-  }, 500);
-
-  // edit detection
-  var isFormChanged = false;
-  $(window).on('beforeunload', function(e) {
-    if (isFormChanged) {
-      // TODO i18n
-      return 'You haven\'t finished your comment yet. Do you want to leave without finishing?';
-    }
-  });
-  $('#form-body').on('keyup change', function(e) {
-    var content = $('#form-body').val();
-    if (originalContent != content) {
-      isFormChanged = true;
-      crowi.saveDraft(pagePath, content);
-    } else {
-      isFormChanged = false;
-      crowi.clearDraft(pagePath);
-    }
-  });
-  */
-  $('#page-form').on('submit', function(e) {
-    // avoid message
-    // isFormChanged = false;
-    window.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');
-    mode = mode || 'after';
-
-    switch (mode) {
-    case 'before':
-      editor.setSelectionRange(start, start);
-      break;
-    case 'replace':
-      editor.setSelectionRange(start, end);
-      break;
-    case 'after':
-    default:
-      editor.setSelectionRange(end, end);
-    }
-
-    editor.focus();
-
-    var inserted = false;
-    try {
-      // Chrome, Safari
-      inserted = document.execCommand('insertText', false, newText);
-    } catch (e) {
-      inserted = false;
-    }
-
-    if (!inserted) {
-      // Firefox
-      editor.value = editor.value.substr(0, start) + newText + editor.value.substr(end);
-    }
-  };
-
-  var getCurrentLine = function(event) {
-    var $target = $(event.target);
-
-    var text = $target.val();
-    var pos = $target.selection('getPos');
-    if (text === null || pos.start !== pos.end) {
-      return null;
-    }
-
-    var startPos = text.lastIndexOf("\n", pos.start - 1) + 1;
-    var endPos = text.indexOf("\n", pos.start);
-    if (endPos === -1) {
-      endPos = text.length;
-    }
-
-    return {
-      text: text.slice(startPos, endPos),
-      start: startPos,
-      end: endPos,
-      caret: pos.start,
-      endOfLine: !$.trim(text.slice(pos.start, endPos))
-    };
-  };
-
-  var getPrevLine = function(event) {
-    var $target = $(event.target);
-    var currentLine = getCurrentLine(event);
-    var text = $target.val().slice(0, currentLine.start);
-    var startPos = text.lastIndexOf("\n", currentLine.start - 2) + 1;
-    var endPos = currentLine.start;
-
-    return {
-      text: text.slice(startPos, endPos),
-      start: startPos,
-      end: endPos
-    };
-  };
-
-  var handleTabKey = function(event) {
-    event.preventDefault();
-
-    var $target = $(event.target);
-    var currentLine = getCurrentLine(event);
-    var text = $target.val();
-    var pos = $target.selection('getPos');
-
-    // When the user presses CTRL + TAB, it is a case to control the tab of the browser
-    // (for Firefox 54 on Windows)
-    if (event.ctrlKey === true) {
-      return;
-    }
-
-    if (currentLine) {
-      $target.selection('setPos', {start: currentLine.start, end: (currentLine.end - 1)});
-    }
-
-    if (event.shiftKey === true) {
-      if (currentLine && currentLine.text.charAt(0) === '|') {
-        // prev cell in table
-        var newPos = text.lastIndexOf('|', pos.start - 1);
-        if (newPos > 0) {
-          $target.selection('setPos', {start: newPos - 1, end: newPos - 1});
-        }
-      } else {
-        // re indent
-        var reindentedText = $target.selection().replace(/^ {1,4}/gm, '');
-        var reindentedCount = $target.selection().length - reindentedText.length;
-        $target.selection('replace', {text: reindentedText, mode: 'before'});
-        if (currentLine) {
-          $target.selection('setPos', {start: pos.start - reindentedCount, end: pos.start - reindentedCount});
-        }
-      }
-    } else {
-      if (currentLine && currentLine.text.charAt(0) === '|') {
-        // next cell in table
-        var newPos = text.indexOf('|', pos.start + 1);
-        if (newPos < 0 || newPos === text.lastIndexOf('|', currentLine.end - 1)) {
-          $target.selection('setPos', {start: currentLine.end, end: currentLine.end});
-        } else {
-          $target.selection('setPos', {start: newPos + 2, end: newPos + 2});
-        }
-      } else {
-        // indent
-        $target.selection('replace', {
-          text: '    ' + $target.selection().split("\n").join("\n    "),
-          mode: 'before'
-        });
-        if (currentLine) {
-          $target.selection('setPos', {start: pos.start + 4, end: pos.start + 4});
-        }
-      }
-    }
-
-    $target.trigger('input');
-  };
-
-  var handleEnterKey = function(event) {
-    if (event.metaKey || event.ctrlKey || event.shiftKey) {
-      return;
-    }
-
-    var currentLine = getCurrentLine(event);
-    if (!currentLine || currentLine.start === currentLine.caret) {
-      return;
-    }
-
-    var $target = $(event.target);
-    var match = currentLine.text.match(/^(\s*(?:-|\+|\*|\d+\.) (?:\[(?:x| )\] )?)\s*\S/);
-    if (match) {
-      // smart indent with list
-      if (currentLine.text.match(/^(\s*(?:-|\+|\*|\d+\.) (?:\[(?:x| )\] ))\s*$/)) {
-        // empty task list
-        $target.selection('setPos', {start: currentLine.start, end: (currentLine.end - 1)});
-        return;
-      }
-      event.preventDefault();
-      var listMark = match[1].replace(/\[x\]/, '[ ]');
-      var listMarkMatch = listMark.match(/^(\s*)(\d+)\./);
-      if (listMarkMatch) {
-        var indent = listMarkMatch[1];
-        var num = parseInt(listMarkMatch[2]);
-        if (num !== 1) {
-          listMark = listMark.replace(/\s*\d+/, indent + (num +1));
-        }
-      }
-      //$target.selection('insert', {text: "\n" + listMark, mode: 'before'});
-      var pos = $target.selection('getPos');
-      insertText(pos.start, pos.start, "\n" + listMark, 'replace');
-      var newPosition = pos.start + ("\n" + listMark).length;
-      $target.selection('setPos', {start: newPosition, end: newPosition});
-    } else if (currentLine.text.match(/^(\s*(?:-|\+|\*|\d+\.) )/)) {
-      // remove list
-      $target.selection('setPos', {start: currentLine.start, end: currentLine.end});
-    } else if (currentLine.text.match(/^.*\|\s*$/)) {
-      // new row for table
-      if (currentLine.text.match(/^[\|\s]+$/)) {
-        $target.selection('setPos', {start: currentLine.start, end: currentLine.end});
-        return;
-      }
-      if (!currentLine.endOfLine) {
-        return;
-      }
-      event.preventDefault();
-      var row = [];
-      var cellbarMatch = currentLine.text.match(/\|/g);
-      for (var i = 0; i < cellbarMatch.length; i++) {
-        row.push('|');
-      }
-      var prevLine = getPrevLine(event);
-      if (!prevLine || (!currentLine.text.match(/---/) && !prevLine.text.match(/\|/g))) {
-        //$target.selection('insert', {text: "\n" + row.join(' --- ') + "\n" + row.join('  '), mode: 'before'});
-        var pos = $target.selection('getPos');
-        insertText(pos.start, pos.start, "\n" + row.join(' --- ') + "\n" + row.join('  '), 'after');
-        $target.selection('setPos', {start: currentLine.caret + 6 * row.length - 1, end: currentLine.caret + 6 * row.length - 1});
-      } else {
-        //$target.selection('insert', {text: "\n" + row.join('  '), mode: 'before'});
-        var pos = $target.selection('getPos');
-        insertText(pos.start, pos.end, "\n" + row.join('  '), 'after');
-        $target.selection('setPos', {start: currentLine.caret + 3, end: currentLine.caret + 3});
-      }
-    }
-
-    $target.trigger('input');
-  };
-
-  var handleEscapeKey = function(event) {
-    event.preventDefault();
-    var $target = $(event.target);
-    $target.blur();
-  };
-
-  var handleSpaceKey = function(event) {
-    // keybind: alt + shift + space
-    if (!(event.shiftKey && event.altKey)) {
-      return;
-    }
-    var currentLine = getCurrentLine(event);
-    if (!currentLine) {
-      return;
-    }
-
-    var $target = $(event.target);
-    var match = currentLine.text.match(/^(\s*)(-|\+|\*|\d+\.) (?:\[(x| )\] )(.*)/);
-    if (match) {
-      event.preventDefault();
-      var checkMark = (match[3] == ' ') ? 'x' : ' ';
-      var replaceTo = match[1] + match[2] + ' [' + checkMark + '] ' + match[4];
-      $target.selection('setPos', {start: currentLine.start, end: currentLine.end});
-      //$target.selection('replace', {text: replaceTo, mode: 'keep'});
-      insertText(currentLine.start, currentLine.end, replaceTo, 'replace');
-      $target.selection('setPos', {start: currentLine.caret, end: currentLine.caret});
-      $target.trigger('input');
-    }
-  };
-
-  var handleSKey = function(event) {
-    if (!event.ctrlKey && !event.metaKey) {
-      return;
-    }
-
-    event.preventDefault();
-
-    const revisionInput = $('#page-form [name="pageForm[currentRevision]"]');
-
-    // generate data to post
-    const body = $('#form-body').val();
-    let endpoint;
-    let data;
-
-    // update
-    if (pageId) {
-      endpoint = '/pages.update';
-      data = {
-        page_id: pageId,
-        revision_id: revisionInput.val(),
-        body: body,
-      };
-    }
-    // create
-    else {
-      endpoint = '/pages.create';
-      data = {
-        path: pagePath,
-        body: body,
-      };
-    }
-
-    crowi.apiPost(endpoint, data)
-      .then((res) => {
-        let page = res.page;
-        pageId = page._id
-
-        toastr.success(undefined, 'Saved successfully', {
-          closeButton: true,
-          progressBar: true,
-          newestOnTop: false,
-          showDuration: "100",
-          hideDuration: "100",
-          timeOut: "1200",
-          extendedTimeOut: "150",
-        });
-
-        // update currentRevision input
-        revisionInput.val(page.revision._id);
-
-        // TODO update $('#revision-body-content')
-      })
-      .catch((error) => {
-        console.error(error);
-        toastr.error(error.message, 'Error occured on saveing', {
-          closeButton: true,
-          progressBar: true,
-          newestOnTop: false,
-          showDuration: "100",
-          hideDuration: "100",
-          timeOut: "3000",
-        });
-      });
-  }
-
-  // markdown helper inspired by 'esarea'.
-  // see: https://github.com/fukayatsu/esarea
-  $('textarea#form-body').on('keydown', function(event) {
-    switch (event.which || event.keyCode) {
-      case 9:
-        handleTabKey(event);
-        break;
-      case 13:
-        handleEnterKey(event);
-        break;
-      case 27:
-        handleEscapeKey(event);
-        break;
-      case 32:
-        handleSpaceKey(event);
-        break;
-      case 83:
-        handleSKey(event);
-        break;
-      default:
-    }
-  });
-
-  var handlePasteEvent = function(event) {
-    var currentLine = getCurrentLine(event);
-
-    if (!currentLine) {
-      return false;
-    }
-    var $target = $(event.target);
-    var pasteText = event.clipboardData.getData('text');
-
-    var match = currentLine.text.match(/^(\s*(?:>|\-|\+|\*|\d+\.) (?:\[(?:x| )\] )?)/);
-    if (match) {
-      if (pasteText.match(/(?:\r\n|\r|\n)/)) {
-        pasteText = pasteText.replace(/(\r\n|\r|\n)/g, "$1" + match[1]);
-      }
-    }
-
-    //$target.selection('insert', {text: pasteText, mode: 'after'});
-    insertText(currentLine.caret, currentLine.caret, pasteText, 'replace');
-
-    var newPos = currentLine.caret + pasteText.length;
-    $target.selection('setPos', {start: newPos, end: newPos});
-
-    return true;
-  };
-
-  document.getElementById('form-body').addEventListener('paste', function(event) {
-    if (handlePasteEvent(event)) {
-      event.preventDefault();
-    }
-  });
-
-  var unbindInlineAttachment = function($form) {
-    $form.unbind('.inlineattach');
-  };
-  var bindInlineAttachment = function($form, attachmentOption) {
-    var $this = $form;
-    var editor = createEditorInstance($form);
-    var inlineattach = new inlineAttachment(attachmentOption, editor);
-    $form.bind({
-      'paste.inlineattach': function(e) {
-        inlineattach.onPaste(e.originalEvent);
-      },
-      'drop.inlineattach': function(e) {
-        e.stopPropagation();
-        e.preventDefault();
-        inlineattach.onDrop(e.originalEvent);
-      },
-      'dragenter.inlineattach dragover.inlineattach': function(e) {
-        e.stopPropagation();
-        e.preventDefault();
-      }
-    });
-  };
-  var createEditorInstance = function($form) {
-    var $this = $form;
-
-    return {
-      getValue: function() {
-        return $this.val();
-      },
-      insertValue: function(val) {
-        inlineAttachment.util.insertTextAtCursor($this[0], val);
-      },
-      setValue: function(val) {
-        $this.val(val);
-      }
-    };
-  };
-
-  var $inputForm = $('form.uploadable textarea#form-body');
-  if ($inputForm.length > 0) {
-    var csrfToken = $('form.uploadable input#edit-form-csrf').val();
-    var pageId = $('#content-main').data('page-id') || 0;
-    var attachmentOption = {
-      uploadUrl: '/_api/attachments.add',
-      extraParams: {
-        path: location.pathname,
-        page_id: pageId,
-        _csrf: csrfToken
-      },
-      progressText: '(Uploading file...)',
-      jsonFieldName: 'url',
-    };
-
-    // if files upload is set
-    var config = crowi.getConfig();
-    if (config.upload.file) {
-      attachmentOption.allowedTypes = '*';
-    }
-
-    attachmentOption.remoteFilename = function(file) {
-      return file.name;
-    };
-
-    attachmentOption.onFileReceived = function(file) {
-      // if not image
-      if (!file.type.match(/^image\/.+$/)) {
-        // modify urlText with 'a' tag
-        this.settings.urlText = `<a href="{filename}">${file.name}</a>\n`;
-        this.settings.urlText = `[${file.name}]({filename})\n`;
-      } else {
-        this.settings.urlText = `![${file.name}]({filename})\n`;
-      }
-    }
-
-    attachmentOption.onFileUploadResponse = function(res) {
-      var result = JSON.parse(res.response);
-
-      if (result.ok && result.pageCreated) {
-        var page = result.page,
-            pageId = page._id;
-
-        $('#content-main').data('page-id', page._id);
-        $('#page-form [name="pageForm[currentRevision]"]').val(page.revision._id)
-
-        unbindInlineAttachment($inputForm);
-
-        attachmentOption.extraParams.page_id = pageId;
-        bindInlineAttachment($inputForm, attachmentOption);
-      }
-      return true;
-    };
-
-    bindInlineAttachment($inputForm, attachmentOption);
-
-    $('textarea#form-body').on('dragenter dragover', function() {
-      $(this).addClass('dragover');
-    });
-    $('textarea#form-body').on('drop dragleave dragend', function() {
-      $(this).removeClass('dragover');
-    });
-  }
-
-  var enableScrollSync = function() {
-    var getMaxScrollTop = function(dom) {
-      var rect = dom.getBoundingClientRect();
-
-      return dom.scrollHeight - rect.height;
-    };
-
-    var getScrollRate = function(dom) {
-      var maxScrollTop = getMaxScrollTop(dom);
-      var rate = dom.scrollTop / maxScrollTop;
-
-      return rate;
-    };
-
-    var getScrollTop = function(dom, rate) {
-      var maxScrollTop = getMaxScrollTop(dom);
-      var top = maxScrollTop * rate;
-
-      return top;
-    };
-
-    var editor = document.querySelector('#form-body');
-    var preview = document.querySelector('#preview-body');
-
-    editor.addEventListener('scroll', function(event) {
-      var rate = getScrollRate(this);
-      var top = getScrollTop(preview, rate);
-
-      preview.scrollTop = top;
-    });
-  };
-  enableScrollSync();
-  */
-});
+// });

+ 2 - 2
resource/js/legacy/crowi-presentation.js

@@ -1,4 +1,4 @@
-var Reveal = require('reveal.js');
+const Reveal = require('reveal.js');
 
 require('reveal.js/css/reveal.css');
 require('reveal.js/css/theme/black.css');
@@ -46,7 +46,7 @@ require.ensure([], () => {
 Reveal.addEventListener('ready', function(event) {
   // event.currentSlide, event.indexh, event.indexv
   $('.reveal section').each(function(e) {
-    var $self = $(this);
+    const $self = $(this);
     if ($self.children().length == 1) {
       $self.addClass('only');
     }

+ 150 - 314
resource/js/legacy/crowi.js

@@ -17,6 +17,7 @@ const io = require('socket.io-client');
 const entities = require('entities');
 const escapeStringRegexp = require('escape-string-regexp');
 require('jquery.cookie');
+require('bootstrap-select');
 
 require('./thirdparty-js/agile-admin');
 const pagePathUtil = require('../../../lib/util/pagePathUtil');
@@ -28,10 +29,6 @@ if (!window) {
 }
 window.Crowi = Crowi;
 
-Crowi.createErrorView = function(msg) {
-  $('#main').prepend($('<p class="alert-message error">' + msg + '</p>'));
-};
-
 /**
  * render Table Of Contents
  * @param {string} tocHtml
@@ -104,30 +101,30 @@ Crowi.userPicture = function(user) {
 };
 
 Crowi.modifyScrollTop = function() {
-  var offset = 10;
+  const offset = 10;
 
-  var hash = window.location.hash;
+  const hash = window.location.hash;
   if (hash === '') {
     return;
   }
 
-  var pageHeader = document.querySelector('#page-header');
+  const pageHeader = document.querySelector('#page-header');
   if (!pageHeader) {
     return;
   }
-  var pageHeaderRect = pageHeader.getBoundingClientRect();
+  const pageHeaderRect = pageHeader.getBoundingClientRect();
 
-  var sectionHeader = Crowi.findSectionHeader(hash);
+  const sectionHeader = Crowi.findSectionHeader(hash);
   if (sectionHeader === null) {
     return;
   }
 
-  var timeout = 0;
+  let timeout = 0;
   if (window.scrollY === 0) {
     timeout = 200;
   }
   setTimeout(function() {
-    var sectionHeaderRect = sectionHeader.getBoundingClientRect();
+    const sectionHeaderRect = sectionHeader.getBoundingClientRect();
     if (sectionHeaderRect.top >= pageHeaderRect.bottom) {
       return;
     }
@@ -136,11 +133,6 @@ Crowi.modifyScrollTop = function() {
   }, timeout);
 };
 
-Crowi.updatePageForm = function(page) {
-  $('#page-form [name="pageForm[currentRevision]"]').val(page.revision._id);
-  $('#page-form [name="pageForm[grant]"]').val(page.grant);
-};
-
 Crowi.handleKeyEHandler = (event) => {
   // ignore when dom that has 'modal in' classes exists
   if (document.getElementsByClassName('modal in').length > 0) {
@@ -221,23 +213,83 @@ Crowi.initSlimScrollForRevisionToc = () => {
   });
 };
 
+Crowi.findHashFromUrl = function(url) {
+  let match;
+  /* eslint-disable no-cond-assign */
+  if (match = url.match(/#(.+)$/)) {
+    return `#${match[1]}`;
+  }
+  /* eslint-enable */
+
+  return '';
+};
+
+Crowi.findSectionHeader = function(hash) {
+  if (hash.length == 0) {
+    return;
+  }
+
+  // omit '#'
+  const id = hash.replace('#', '');
+  // don't use jQuery and document.querySelector
+  //  because hash may containe Base64 encoded strings
+  const elem = document.getElementById(id);
+  if (elem != null && elem.tagName.match(/h\d+/i)) {  // match h1, h2, h3...
+    return elem;
+  }
+
+  return null;
+};
+
+Crowi.unhighlightSelectedSection = function(hash) {
+  const elem = Crowi.findSectionHeader(hash);
+  if (elem != null) {
+    elem.classList.remove('highlighted');
+  }
+};
+
+Crowi.highlightSelectedSection = function(hash) {
+  const elem = Crowi.findSectionHeader(hash);
+  if (elem != null) {
+    elem.classList.add('highlighted');
+  }
+};
+
+/**
+ * Return editor mode string
+ * @return 'builtin' or 'hackmd' or null (not editing)
+ */
+Crowi.getCurrentEditorMode = function() {
+  const isEditing = $('body').hasClass('on-edit');
+  if (!isEditing) {
+    return null;
+  }
+
+  if ($('body').hasClass('builtin-editor')) {
+    return 'builtin';
+  }
+  else {
+    return 'hackmd';
+  }
+};
+
 $(function() {
-  var config = JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}');
+  const config = JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}');
 
-  var pageId = $('#content-main').data('page-id');
-  // var revisionId = $('#content-main').data('page-revision-id');
-  // var revisionCreatedAt = $('#content-main').data('page-revision-created');
-  // var currentUser = $('#content-main').data('current-user');
-  var isSeen = $('#content-main').data('page-is-seen');
-  var pagePath= $('#content-main').data('path');
-  var isSavedStatesOfTabChanges = config['isSavedStatesOfTabChanges'];
+  const pageId = $('#content-main').data('page-id');
+  // const revisionId = $('#content-main').data('page-revision-id');
+  // const revisionCreatedAt = $('#content-main').data('page-revision-created');
+  // const currentUser = $('#content-main').data('current-user');
+  const isSeen = $('#content-main').data('page-is-seen');
+  const pagePath= $('#content-main').data('path');
+  const isSavedStatesOfTabChanges = config['isSavedStatesOfTabChanges'];
 
   $('[data-toggle="popover"]').popover();
   $('[data-toggle="tooltip"]').tooltip();
   $('[data-tooltip-stay]').tooltip('show');
 
   $('#toggle-sidebar').click(function(e) {
-    var $mainContainer = $('.main-container');
+    const $mainContainer = $('.main-container');
     if ($mainContainer.hasClass('aside-hidden')) {
       $('.main-container').removeClass('aside-hidden');
       $.cookie('aside-hidden', 0, { expires: 30, path: '/' });
@@ -260,10 +312,10 @@ $(function() {
 
   $('#create-page').on('shown.bs.modal', function(e) {
     // quick hack: replace from server side rendering "date" to client side "date"
-    var today = new Date();
-    var month = ('0' + (today.getMonth() + 1)).slice(-2);
-    var day = ('0' + today.getDate()).slice(-2);
-    var dateString = today.getFullYear() + '/' + month + '/' + day;
+    const today = new Date();
+    const month = ('0' + (today.getMonth() + 1)).slice(-2);
+    const day = ('0' + today.getDate()).slice(-2);
+    const dateString = today.getFullYear() + '/' + month + '/' + day;
     $('#create-page-today .page-today-suffix').text('/' + dateString + '/');
     $('#create-page-today .page-today-input2').data('prefix', '/' + dateString + '/');
 
@@ -272,10 +324,10 @@ $(function() {
   });
 
   $('#create-page-today').submit(function(e) {
-    var prefix1 = $('input.page-today-input1', this).data('prefix');
-    var input1 = $('input.page-today-input1', this).val();
-    var prefix2 = $('input.page-today-input2', this).data('prefix');
-    var input2 = $('input.page-today-input2', this).val();
+    let prefix1 = $('input.page-today-input1', this).data('prefix');
+    let prefix2 = $('input.page-today-input2', this).data('prefix');
+    const input1 = $('input.page-today-input1', this).val();
+    const input2 = $('input.page-today-input2', this).val();
     if (input1 === '') {
       prefix1 = 'メモ';
     }
@@ -287,7 +339,7 @@ $(function() {
   });
 
   $('#create-page-under-tree').submit(function(e) {
-    var name = $('input', this).val();
+    let name = $('input', this).val();
     if (!name.match(/^\//)) {
       name = '/' + name;
     }
@@ -325,7 +377,7 @@ $(function() {
         `);
       }
       else {
-        var page = res.page;
+        const page = res.page;
         top.location.href = page.path + '?renamed=' + pagePath;
       }
     });
@@ -359,7 +411,7 @@ $(function() {
         `);
       }
       else {
-        var page = res.page;
+        const page = res.page;
         top.location.href = page.path + '?duplicated=' + pagePath;
       }
     });
@@ -380,7 +432,7 @@ $(function() {
         $('#delete-errors').addClass('alert-danger');
       }
       else {
-        var page = res.page;
+        const page = res.page;
         top.location.href = page.path;
       }
     });
@@ -399,7 +451,7 @@ $(function() {
         $('#delete-errors').addClass('alert-danger');
       }
       else {
-        var page = res.page;
+        const page = res.page;
         top.location.href = page.path;
       }
     });
@@ -419,7 +471,7 @@ $(function() {
         $('#delete-errors').addClass('alert-danger');
       }
       else {
-        var page = res.page;
+        const page = res.page;
         top.location.href = page.path + '?unlinked=true';
       }
     });
@@ -430,9 +482,9 @@ $(function() {
   $('#create-portal-button').on('click', function(e) {
     $('body').addClass('on-edit');
 
-    var path = $('.content-main').data('path');
+    const path = $('.content-main').data('path');
     if (path != '/' && $('.content-main').data('page-id') == '') {
-      var upperPage = path.substr(0, path.length - 1);
+      const upperPage = path.substr(0, path.length - 1);
       $.get('/_api/pages.get', {path: upperPage}, function(res) {
         if (res.ok && res.page) {
           $('#portal-warning-modal').modal('show');
@@ -449,12 +501,12 @@ $(function() {
    * wrap short path with <strong></strong>
    */
   $('#view-list .page-list-ul-flat .page-list-link').each(function() {
-    var $link = $(this);
+    const $link = $(this);
     /* eslint-disable no-unused-vars */
-    var text = $link.text();
+    const text = $link.text();
     /* eslint-enable */
-    var path = decodeURIComponent($link.data('path'));
-    var shortPath = decodeURIComponent($link.data('short-path')); // convert to string
+    let path = decodeURIComponent($link.data('path'));
+    const shortPath = decodeURIComponent($link.data('short-path')); // convert to string
 
     if (path == null || shortPath == null) {
       // continue
@@ -462,7 +514,7 @@ $(function() {
     }
 
     path = entities.encodeHTML(path);
-    var pattern = escapeStringRegexp(entities.encodeHTML(shortPath)) + '(/)?$';
+    const pattern = escapeStringRegexp(entities.encodeHTML(shortPath)) + '(/)?$';
 
     $link.html(path.replace(new RegExp(pattern), '<strong>' + shortPath + '$1</strong>'));
   });
@@ -470,7 +522,7 @@ $(function() {
   // for list page
   let growiRendererForTimeline = null;
   $('a[data-toggle="tab"][href="#view-timeline"]').on('show.bs.tab', function() {
-    var isShown = $('#view-timeline').data('shown');
+    const isShown = $('#view-timeline').data('shown');
 
     if (growiRendererForTimeline == null) {
       growiRendererForTimeline = new GrowiRenderer(crowi, crowiRenderer, {mode: 'timeline'});
@@ -478,15 +530,15 @@ $(function() {
 
     if (isShown == 0) {
       $('#view-timeline .timeline-body').each(function() {
-        var id = $(this).attr('id');
-        var contentId = '#' + id + ' > script';
-        var revisionBody = '#' + id + ' .revision-body';
-        var revisionBodyElem = document.querySelector(revisionBody);
+        const id = $(this).attr('id');
+        const contentId = '#' + id + ' > script';
+        const revisionBody = '#' + id + ' .revision-body';
+        const revisionBodyElem = document.querySelector(revisionBody);
         /* eslint-disable no-unused-vars */
-        var revisionPath = '#' + id + ' .revision-path';
+        const revisionPath = '#' + id + ' .revision-path';
         /* eslint-enable */
-        var pagePath = document.getElementById(id).getAttribute('data-page-path');
-        var markdown = entities.decodeHTML($(contentId).html());
+        const pagePath = document.getElementById(id).getAttribute('data-page-path');
+        const markdown = entities.decodeHTML($(contentId).html());
 
         ReactDOM.render(<Page crowi={crowi} crowiRenderer={growiRendererForTimeline} markdown={markdown} pagePath={pagePath} />, revisionBodyElem);
       });
@@ -499,77 +551,19 @@ $(function() {
 
     // for Crowi Template LangProcessor
     $('.template-create-button', $('#revision-body')).on('click', function() {
-      var path = $(this).data('path');
-      var templateId = $(this).data('template');
-      var template = $('#' + templateId).html();
+      const path = $(this).data('path');
+      const templateId = $(this).data('template');
+      const template = $('#' + templateId).html();
 
       crowi.saveDraft(path, template);
       top.location.href = `${path}#edit`;
     });
 
-    /*
-     * transplanted to React components -- 2018.02.04 Yuki Takei
-     *
-
-    // if page exists
-    var $rawTextOriginal = $('#raw-text-original');
-    if ($rawTextOriginal.length > 0) {
-      var markdown = entities.decodeHTML($('#raw-text-original').html());
-      var dom = $('#revision-body-content').get(0);
-
-      // create context object
-      var context = {
-        markdown,
-        dom,
-        currentPagePath: decodeURIComponent(location.pathname)
-      };
-
-      crowi.interceptorManager.process('preRender', context)
-        .then(() => crowi.interceptorManager.process('prePreProcess', context))
-        .then(() => {
-          context.markdown = crowiRenderer.preProcess(context.markdown);
-        })
-        .then(() => crowi.interceptorManager.process('postPreProcess', context))
-        .then(() => {
-          var revisionBody = $('#revision-body-content');
-          var parsedHTML = crowiRenderer.render(context.markdown, context.dom);
-          context.parsedHTML = parsedHTML;
-          Promise.resolve(context);
-        })
-        .then(() => crowi.interceptorManager.process('postRender', context))
-        .then(() => crowi.interceptorManager.process('preRenderHtml', context))
-        // render HTML with jQuery
-        .then(() => {
-          $('#revision-body-content').html(context.parsedHTML);
-
-          $('.template-create-button').on('click', function() {
-            var path = $(this).data('path');
-            var templateId = $(this).data('template');
-            var template = $('#' + templateId).html();
-
-            crowi.saveDraft(path, template);
-            top.location.href = path;
-          });
-
-          Crowi.appendEditSectionButtons('#revision-body-content', markdown);
-
-          Promise.resolve($('#revision-body-content'));
-        })
-        // process interceptors for post rendering
-        .then((bodyElement) => {
-          context = Object.assign(context, {bodyElement})
-          return crowi.interceptorManager.process('postRenderHtml', context);
-        });
-
-
-    }
-    */
-
     // header affix
-    var $affixContent = $('#page-header');
+    const $affixContent = $('#page-header');
     if ($affixContent.length > 0) {
-      var $affixContentContainer = $('.row.bg-title');
-      var containerHeight = $affixContentContainer.outerHeight(true);
+      const $affixContentContainer = $('.row.bg-title');
+      const containerHeight = $affixContentContainer.outerHeight(true);
       $affixContent.affix({
         offset: {
           top: function() {
@@ -578,144 +572,19 @@ $(function() {
         }
       });
       $('[data-affix-disable]').on('click', function(e) {
-        var $elm = $($(this).data('affix-disable'));
+        const $elm = $($(this).data('affix-disable'));
         $(window).off('.affix');
         $elm.removeData('affix').removeClass('affix affix-top affix-bottom');
         return false;
       });
     }
 
-    // (function () {
-    //   var timer = 0;
-
-    //   window.onresize = function () {
-    //     if (timer > 0) {
-    //       clearTimeout(timer);
-    //     }
-
-    //     timer = setTimeout(function () {
-    //       DrawScrollbar();
-    //     }, 200);
-    //   };
-    // }());
-
-    /*
-     * transplanted to React components -- 2017.06.02 Yuki Takei
-     *
-
-    // omg
-    function createCommentHTML(revision, creator, comment, commentedAt) {
-      var $comment = $('<div>');
-      var $commentImage = $('<img class="picture img-circle">')
-        .attr('src', Crowi.userPicture(creator));
-      var $commentCreator = $('<div class="page-comment-creator">')
-        .text(creator.username);
-
-      var $commentRevision = $('<a class="page-comment-revision label">')
-        .attr('href', '?revision=' + revision)
-        .text(revision.substr(0,8));
-      if (revision !== revisionId) {
-        $commentRevision.addClass('label-default');
-      } else {
-        $commentRevision.addClass('label-primary');
-      }
-
-      var $commentMeta = $('<div class="page-comment-meta">')
-        //日付変換
-        .text(moment(commentedAt).format('YYYY/MM/DD HH:mm:ss') + ' ')
-        .append($commentRevision);
-
-      var $commentBody = $('<div class="page-comment-body">')
-        .html(comment.replace(/(\r\n|\r|\n)/g, '<br>'));
-
-      var $commentMain = $('<div class="page-comment-main">')
-        .append($commentCreator)
-        .append($commentBody)
-        .append($commentMeta)
-
-      $comment.addClass('page-comment');
-      if (creator._id === currentUser) {
-        $comment.addClass('page-comment-me');
-      }
-      if (revision !== revisionId) {
-        $comment.addClass('page-comment-old');
-      }
-      $comment
-        .append($commentImage)
-        .append($commentMain);
-
-      return $comment;
-    }
-
-    // get comments
-    var $pageCommentList = $('.page-comments-list');
-    var $pageCommentListNewer =   $('#page-comments-list-newer');
-    var $pageCommentListCurrent = $('#page-comments-list-current');
-    var $pageCommentListOlder =   $('#page-comments-list-older');
-    var hasNewer = false;
-    var hasOlder = false;
-    $.get('/_api/comments.get', {page_id: pageId}, function(res) {
-      if (res.ok) {
-        var comments = res.comments;
-        $.each(comments, function(i, comment) {
-          var commentContent = createCommentHTML(comment.revision, comment.creator, comment.comment, comment.createdAt);
-          if (comment.revision == revisionId) {
-            $pageCommentListCurrent.append(commentContent);
-          } else {
-            if (Date.parse(comment.createdAt)/1000 > revisionCreatedAt) {
-              $pageCommentListNewer.append(commentContent);
-              hasNewer = true;
-            } else {
-              $pageCommentListOlder.append(commentContent);
-              hasOlder = true;
-            }
-          }
-        });
-      }
-    }).fail(function(data) {
-
-    }).always(function() {
-      if (!hasNewer) {
-        $('.page-comments-list-toggle-newer').hide();
-      }
-      if (!hasOlder) {
-        $pageCommentListOlder.addClass('collapse');
-        $('.page-comments-list-toggle-older').hide();
-      }
-    });
-
-    // post comment event
-    $('#page-comment-form').on('submit', function() {
-      var $button = $('#comment-form-button');
-      $button.attr('disabled', 'disabled');
-      $.post('/_api/comments.add', $(this).serialize(), function(data) {
-        $button.prop('disabled', false);
-        if (data.ok) {
-          var comment = data.comment;
-
-          $pageCommentList.prepend(createCommentHTML(comment.revision, comment.creator, comment.comment, comment.createdAt));
-          $('#comment-form-comment').val('');
-          $('#comment-form-message').text('');
-        } else {
-          $('#comment-form-message').text(data.error);
-        }
-      }).fail(function(data) {
-        if (data.status !== 200) {
-          $('#comment-form-message').text(data.statusText);
-        }
-      });
-
-      return false;
-    });
-
-    */
-
     // Like
-    var $likeButton = $('.like-button');
-    var $likeCount = $('#like-count');
+    const $likeButton = $('.like-button');
+    const $likeCount = $('#like-count');
     $likeButton.click(function() {
-      var liked = $likeButton.data('liked');
-      var token = $likeButton.data('csrftoken');
+      const liked = $likeButton.data('liked');
+      const token = $likeButton.data('csrftoken');
       if (!liked) {
         $.post('/_api/likes.add', {_csrf: token, page_id: pageId}, function(res) {
           if (res.ok) {
@@ -733,10 +602,10 @@ $(function() {
 
       return false;
     });
-    var $likerList = $('#liker-list');
-    var likers = $likerList.data('likers');
+    const $likerList = $('#liker-list');
+    const likers = $likerList.data('likers');
     if (likers && likers.length > 0) {
-      var users = crowi.findUserByIds(likers.split(','));
+      const users = crowi.findUserByIds(likers.split(','));
       if (users) {
         AddToLikers(users);
       }
@@ -762,12 +631,12 @@ $(function() {
     }
 
     function CreateUserLinkWithPicture(user) {
-      var $userHtml = $('<a>');
+      const $userHtml = $('<a>');
       $userHtml.data('user-id', user._id);
       $userHtml.attr('href', '/user/' + user.username);
       $userHtml.attr('title', user.name);
 
-      var $userPicture = $('<img class="picture picture-xs img-circle">');
+      const $userPicture = $('<img class="picture picture-xs img-circle">');
       $userPicture.attr('alt', user.name);
       $userPicture.attr('src',  Crowi.userPicture(user));
 
@@ -786,11 +655,11 @@ $(function() {
     }
 
     // presentation
-    var presentaionInitialized = false
+    let presentaionInitialized = false
       , $b = $('body');
 
     $(document).on('click', '.toggle-presentation', function(e) {
-      var $a = $(this);
+      const $a = $(this);
 
       e.preventDefault();
       $b.toggleClass('overlay-on');
@@ -807,8 +676,8 @@ $(function() {
     });
 
     //
-    var me = $('body').data('me');
-    var socket = io();
+    const me = $('body').data('me');
+    const socket = io();
     socket.on('page edited', function(data) {
       if (data.user._id != me
         && data.page.path == pagePath) {
@@ -818,6 +687,25 @@ $(function() {
     });
   } // end if pageId
 
+  // tab changing handling
+  $('a[href="#edit"]').on('show.bs.tab', function() {
+    $('body').addClass('on-edit');
+    $('body').addClass('builtin-editor');
+  });
+  $('a[href="#edit"]').on('hide.bs.tab', function() {
+    $('body').removeClass('on-edit');
+    $('body').removeClass('builtin-editor');
+  });
+  $('a[href="#hackmd"]').on('show.bs.tab', function() {
+    $('body').addClass('on-edit');
+    $('body').addClass('hackmd');
+  });
+
+  $('a[href="#hackmd"]').on('hide.bs.tab', function() {
+    $('body').removeClass('on-edit');
+    $('body').removeClass('hackmd');
+  });
+
   // hash handling
   if (isSavedStatesOfTabChanges) {
     $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', function() {
@@ -863,58 +751,6 @@ $(function() {
   });
 });
 
-/*
-Crowi.getRevisionBodyContent = function() {
-  return $('#revision-body-content').html();
-}
-
-Crowi.replaceRevisionBodyContent = function(html) {
-  $('#revision-body-content').html(html);
-}
-*/
-
-Crowi.findHashFromUrl = function(url) {
-  var match;
-  /* eslint-disable no-cond-assign */
-  if (match = url.match(/#(.+)$/)) {
-    return `#${match[1]}`;
-  }
-  /* eslint-enable */
-
-  return '';
-};
-
-Crowi.findSectionHeader = function(hash) {
-  if (hash.length == 0) {
-    return;
-  }
-
-  // omit '#'
-  const id = hash.replace('#', '');
-  // don't use jQuery and document.querySelector
-  //  because hash may containe Base64 encoded strings
-  const elem = document.getElementById(id);
-  if (elem != null && elem.tagName.match(/h\d+/i)) {  // match h1, h2, h3...
-    return elem;
-  }
-
-  return null;
-};
-
-Crowi.unhighlightSelectedSection = function(hash) {
-  const elem = Crowi.findSectionHeader(hash);
-  if (elem != null) {
-    elem.classList.remove('highlighted');
-  }
-};
-
-Crowi.highlightSelectedSection = function(hash) {
-  const elem = Crowi.findSectionHeader(hash);
-  if (elem != null) {
-    elem.classList.add('highlighted');
-  }
-};
-
 window.addEventListener('load', function(e) {
   // hash on page
   if (location.hash) {
@@ -932,10 +768,10 @@ window.addEventListener('load', function(e) {
   }
 
   if (crowi && crowi.users || crowi.users.length == 0) {
-    var totalUsers = crowi.users.length;
-    var $listLiker = $('.page-list-liker');
+    const totalUsers = crowi.users.length;
+    const $listLiker = $('.page-list-liker');
     $listLiker.each(function(i, liker) {
-      var count = $(liker).data('count') || 0;
+      const count = $(liker).data('count') || 0;
       if (count/totalUsers > 0.05) {
         $(liker).addClass('popular-page-high');
         // 5%
@@ -949,9 +785,9 @@ window.addEventListener('load', function(e) {
         // 0.5%
       }
     });
-    var $listSeer = $('.page-list-seer');
+    const $listSeer = $('.page-list-seer');
     $listSeer.each(function(i, seer) {
-      var count = $(seer).data('count') || 0;
+      const count = $(seer).data('count') || 0;
       if (count/totalUsers > 0.10) {
         // 10%
         $(seer).addClass('popular-page-high');

+ 64 - 0
resource/js/legacy/thirdparty-js/switchery/switchery.css

@@ -0,0 +1,64 @@
+/*
+ *
+ * Main stylesheet for Switchery.
+ * http://abpetkov.github.io/switchery/
+ *
+ */
+
+/* Switchery defaults. */
+
+.switchery {
+  background-color: #fff;
+  border: 1px solid #dfdfdf;
+  border-radius: 20px;
+  cursor: pointer;
+  display: inline-block;
+  height: 30px;
+  position: relative;
+  vertical-align: middle;
+  width: 50px;
+
+  -moz-user-select: none;
+  -khtml-user-select: none;
+  -webkit-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+  box-sizing: content-box;
+  background-clip: content-box;
+}
+
+.switchery > small {
+  background: #fff;
+  border-radius: 100%;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
+  height: 30px;
+  position: absolute;
+  top: 0;
+  width: 30px;
+}
+
+/* Switchery sizes. */
+
+.switchery-small {
+  border-radius: 20px;
+  height: 20px;
+  width: 33px;
+}
+
+.switchery-small > small {
+  height: 20px;
+  width: 20px;
+}
+
+.switchery-large {
+  border-radius: 40px;
+  height: 40px;
+  width: 66px;
+}
+
+.switchery-large > small {
+  height: 40px;
+  width: 40px;
+}
+
+

+ 1957 - 0
resource/js/legacy/thirdparty-js/switchery/switchery.js

@@ -0,0 +1,1957 @@
+
+;(function(){
+
+/**
+ * Require the module at `name`.
+ *
+ * @param {String} name
+ * @return {Object} exports
+ * @api public
+ */
+
+function require(name) {
+  var module = require.modules[name];
+  if (!module) throw new Error('failed to require "' + name + '"');
+
+  if (!('exports' in module) && typeof module.definition === 'function') {
+    module.client = module.component = true;
+    module.definition.call(this, module.exports = {}, module);
+    delete module.definition;
+  }
+
+  return module.exports;
+}
+
+/**
+ * Meta info, accessible in the global scope unless you use AMD option.
+ */
+
+require.loader = 'component';
+
+/**
+ * Internal helper object, contains a sorting function for semantiv versioning
+ */
+require.helper = {};
+require.helper.semVerSort = function(a, b) {
+  var aArray = a.version.split('.');
+  var bArray = b.version.split('.');
+  for (var i=0; i<aArray.length; ++i) {
+    var aInt = parseInt(aArray[i], 10);
+    var bInt = parseInt(bArray[i], 10);
+    if (aInt === bInt) {
+      var aLex = aArray[i].substr((""+aInt).length);
+      var bLex = bArray[i].substr((""+bInt).length);
+      if (aLex === '' && bLex !== '') return 1;
+      if (aLex !== '' && bLex === '') return -1;
+      if (aLex !== '' && bLex !== '') return aLex > bLex ? 1 : -1;
+      continue;
+    } else if (aInt > bInt) {
+      return 1;
+    } else {
+      return -1;
+    }
+  }
+  return 0;
+}
+
+/**
+ * Find and require a module which name starts with the provided name.
+ * If multiple modules exists, the highest semver is used.
+ * This function can only be used for remote dependencies.
+
+ * @param {String} name - module name: `user~repo`
+ * @param {Boolean} returnPath - returns the canonical require path if true,
+ *                               otherwise it returns the epxorted module
+ */
+require.latest = function (name, returnPath) {
+  function showError(name) {
+    throw new Error('failed to find latest module of "' + name + '"');
+  }
+  // only remotes with semvers, ignore local files conataining a '/'
+  var versionRegexp = /(.*)~(.*)@v?(\d+\.\d+\.\d+[^\/]*)$/;
+  var remoteRegexp = /(.*)~(.*)/;
+  if (!remoteRegexp.test(name)) showError(name);
+  var moduleNames = Object.keys(require.modules);
+  var semVerCandidates = [];
+  var otherCandidates = []; // for instance: name of the git branch
+  for (var i=0; i<moduleNames.length; i++) {
+    var moduleName = moduleNames[i];
+    if (new RegExp(name + '@').test(moduleName)) {
+        var version = moduleName.substr(name.length+1);
+        var semVerMatch = versionRegexp.exec(moduleName);
+        if (semVerMatch != null) {
+          semVerCandidates.push({version: version, name: moduleName});
+        } else {
+          otherCandidates.push({version: version, name: moduleName});
+        }
+    }
+  }
+  if (semVerCandidates.concat(otherCandidates).length === 0) {
+    showError(name);
+  }
+  if (semVerCandidates.length > 0) {
+    var module = semVerCandidates.sort(require.helper.semVerSort).pop().name;
+    if (returnPath === true) {
+      return module;
+    }
+    return require(module);
+  }
+  // if the build contains more than one branch of the same module
+  // you should not use this funciton
+  var module = otherCandidates.sort(function(a, b) {return a.name > b.name})[0].name;
+  if (returnPath === true) {
+    return module;
+  }
+  return require(module);
+}
+
+/**
+ * Registered modules.
+ */
+
+require.modules = {};
+
+/**
+ * Register module at `name` with callback `definition`.
+ *
+ * @param {String} name
+ * @param {Function} definition
+ * @api private
+ */
+
+require.register = function (name, definition) {
+  require.modules[name] = {
+    definition: definition
+  };
+};
+
+/**
+ * Define a module's exports immediately with `exports`.
+ *
+ * @param {String} name
+ * @param {Generic} exports
+ * @api private
+ */
+
+require.define = function (name, exports) {
+  require.modules[name] = {
+    exports: exports
+  };
+};
+require.register("abpetkov~transitionize@0.0.3", function (exports, module) {
+
+/**
+ * Transitionize 0.0.2
+ * https://github.com/abpetkov/transitionize
+ *
+ * Authored by Alexander Petkov
+ * https://github.com/abpetkov
+ *
+ * Copyright 2013, Alexander Petkov
+ * License: The MIT License (MIT)
+ * http://opensource.org/licenses/MIT
+ *
+ */
+
+/**
+ * Expose `Transitionize`.
+ */
+
+module.exports = Transitionize;
+
+/**
+ * Initialize new Transitionize.
+ *
+ * @param {Object} element
+ * @param {Object} props
+ * @api public
+ */
+
+function Transitionize(element, props) {
+  if (!(this instanceof Transitionize)) return new Transitionize(element, props);
+
+  this.element = element;
+  this.props = props || {};
+  this.init();
+}
+
+/**
+ * Detect if Safari.
+ *
+ * @returns {Boolean}
+ * @api private
+ */
+
+Transitionize.prototype.isSafari = function() {
+  return (/Safari/).test(navigator.userAgent) && (/Apple Computer/).test(navigator.vendor);
+};
+
+/**
+ * Loop though the object and push the keys and values in an array.
+ * Apply the CSS3 transition to the element and prefix with -webkit- for Safari.
+ *
+ * @api private
+ */
+
+Transitionize.prototype.init = function() {
+  var transitions = [];
+
+  for (var key in this.props) {
+    transitions.push(key + ' ' + this.props[key]);
+  }
+
+  this.element.style.transition = transitions.join(', ');
+  if (this.isSafari()) this.element.style.webkitTransition = transitions.join(', ');
+};
+});
+
+require.register("ftlabs~fastclick@v0.6.11", function (exports, module) {
+/**
+ * @preserve FastClick: polyfill to remove click delays on browsers with touch UIs.
+ *
+ * @version 0.6.11
+ * @codingstandard ftlabs-jsv2
+ * @copyright The Financial Times Limited [All Rights Reserved]
+ * @license MIT License (see LICENSE.txt)
+ */
+
+/*jslint browser:true, node:true*/
+/*global define, Event, Node*/
+
+
+/**
+ * Instantiate fast-clicking listeners on the specificed layer.
+ *
+ * @constructor
+ * @param {Element} layer The layer to listen on
+ */
+function FastClick(layer) {
+	'use strict';
+	var oldOnClick, self = this;
+
+
+	/**
+	 * Whether a click is currently being tracked.
+	 *
+	 * @type boolean
+	 */
+	this.trackingClick = false;
+
+
+	/**
+	 * Timestamp for when when click tracking started.
+	 *
+	 * @type number
+	 */
+	this.trackingClickStart = 0;
+
+
+	/**
+	 * The element being tracked for a click.
+	 *
+	 * @type EventTarget
+	 */
+	this.targetElement = null;
+
+
+	/**
+	 * X-coordinate of touch start event.
+	 *
+	 * @type number
+	 */
+	this.touchStartX = 0;
+
+
+	/**
+	 * Y-coordinate of touch start event.
+	 *
+	 * @type number
+	 */
+	this.touchStartY = 0;
+
+
+	/**
+	 * ID of the last touch, retrieved from Touch.identifier.
+	 *
+	 * @type number
+	 */
+	this.lastTouchIdentifier = 0;
+
+
+	/**
+	 * Touchmove boundary, beyond which a click will be cancelled.
+	 *
+	 * @type number
+	 */
+	this.touchBoundary = 10;
+
+
+	/**
+	 * The FastClick layer.
+	 *
+	 * @type Element
+	 */
+	this.layer = layer;
+
+	if (!layer || !layer.nodeType) {
+		throw new TypeError('Layer must be a document node');
+	}
+
+	/** @type function() */
+	this.onClick = function() { return FastClick.prototype.onClick.apply(self, arguments); };
+
+	/** @type function() */
+	this.onMouse = function() { return FastClick.prototype.onMouse.apply(self, arguments); };
+
+	/** @type function() */
+	this.onTouchStart = function() { return FastClick.prototype.onTouchStart.apply(self, arguments); };
+
+	/** @type function() */
+	this.onTouchMove = function() { return FastClick.prototype.onTouchMove.apply(self, arguments); };
+
+	/** @type function() */
+	this.onTouchEnd = function() { return FastClick.prototype.onTouchEnd.apply(self, arguments); };
+
+	/** @type function() */
+	this.onTouchCancel = function() { return FastClick.prototype.onTouchCancel.apply(self, arguments); };
+
+	if (FastClick.notNeeded(layer)) {
+		return;
+	}
+
+	// Set up event handlers as required
+	if (this.deviceIsAndroid) {
+		layer.addEventListener('mouseover', this.onMouse, true);
+		layer.addEventListener('mousedown', this.onMouse, true);
+		layer.addEventListener('mouseup', this.onMouse, true);
+	}
+
+	layer.addEventListener('click', this.onClick, true);
+	layer.addEventListener('touchstart', this.onTouchStart, false);
+	layer.addEventListener('touchmove', this.onTouchMove, false);
+	layer.addEventListener('touchend', this.onTouchEnd, false);
+	layer.addEventListener('touchcancel', this.onTouchCancel, false);
+
+	// Hack is required for browsers that don't support Event#stopImmediatePropagation (e.g. Android 2)
+	// which is how FastClick normally stops click events bubbling to callbacks registered on the FastClick
+	// layer when they are cancelled.
+	if (!Event.prototype.stopImmediatePropagation) {
+		layer.removeEventListener = function(type, callback, capture) {
+			var rmv = Node.prototype.removeEventListener;
+			if (type === 'click') {
+				rmv.call(layer, type, callback.hijacked || callback, capture);
+			} else {
+				rmv.call(layer, type, callback, capture);
+			}
+		};
+
+		layer.addEventListener = function(type, callback, capture) {
+			var adv = Node.prototype.addEventListener;
+			if (type === 'click') {
+				adv.call(layer, type, callback.hijacked || (callback.hijacked = function(event) {
+					if (!event.propagationStopped) {
+						callback(event);
+					}
+				}), capture);
+			} else {
+				adv.call(layer, type, callback, capture);
+			}
+		};
+	}
+
+	// If a handler is already declared in the element's onclick attribute, it will be fired before
+	// FastClick's onClick handler. Fix this by pulling out the user-defined handler function and
+	// adding it as listener.
+	if (typeof layer.onclick === 'function') {
+
+		// Android browser on at least 3.2 requires a new reference to the function in layer.onclick
+		// - the old one won't work if passed to addEventListener directly.
+		oldOnClick = layer.onclick;
+		layer.addEventListener('click', function(event) {
+			oldOnClick(event);
+		}, false);
+		layer.onclick = null;
+	}
+}
+
+
+/**
+ * Android requires exceptions.
+ *
+ * @type boolean
+ */
+FastClick.prototype.deviceIsAndroid = navigator.userAgent.indexOf('Android') > 0;
+
+
+/**
+ * iOS requires exceptions.
+ *
+ * @type boolean
+ */
+FastClick.prototype.deviceIsIOS = /iP(ad|hone|od)/.test(navigator.userAgent);
+
+
+/**
+ * iOS 4 requires an exception for select elements.
+ *
+ * @type boolean
+ */
+FastClick.prototype.deviceIsIOS4 = FastClick.prototype.deviceIsIOS && (/OS 4_\d(_\d)?/).test(navigator.userAgent);
+
+
+/**
+ * iOS 6.0(+?) requires the target element to be manually derived
+ *
+ * @type boolean
+ */
+FastClick.prototype.deviceIsIOSWithBadTarget = FastClick.prototype.deviceIsIOS && (/OS ([6-9]|\d{2})_\d/).test(navigator.userAgent);
+
+
+/**
+ * Determine whether a given element requires a native click.
+ *
+ * @param {EventTarget|Element} target Target DOM element
+ * @returns {boolean} Returns true if the element needs a native click
+ */
+FastClick.prototype.needsClick = function(target) {
+	'use strict';
+	switch (target.nodeName.toLowerCase()) {
+
+	// Don't send a synthetic click to disabled inputs (issue #62)
+	case 'button':
+	case 'select':
+	case 'textarea':
+		if (target.disabled) {
+			return true;
+		}
+
+		break;
+	case 'input':
+
+		// File inputs need real clicks on iOS 6 due to a browser bug (issue #68)
+		if ((this.deviceIsIOS && target.type === 'file') || target.disabled) {
+			return true;
+		}
+
+		break;
+	case 'label':
+	case 'video':
+		return true;
+	}
+
+	return (/\bneedsclick\b/).test(target.className);
+};
+
+
+/**
+ * Determine whether a given element requires a call to focus to simulate click into element.
+ *
+ * @param {EventTarget|Element} target Target DOM element
+ * @returns {boolean} Returns true if the element requires a call to focus to simulate native click.
+ */
+FastClick.prototype.needsFocus = function(target) {
+	'use strict';
+	switch (target.nodeName.toLowerCase()) {
+	case 'textarea':
+		return true;
+	case 'select':
+		return !this.deviceIsAndroid;
+	case 'input':
+		switch (target.type) {
+		case 'button':
+		case 'checkbox':
+		case 'file':
+		case 'image':
+		case 'radio':
+		case 'submit':
+			return false;
+		}
+
+		// No point in attempting to focus disabled inputs
+		return !target.disabled && !target.readOnly;
+	default:
+		return (/\bneedsfocus\b/).test(target.className);
+	}
+};
+
+
+/**
+ * Send a click event to the specified element.
+ *
+ * @param {EventTarget|Element} targetElement
+ * @param {Event} event
+ */
+FastClick.prototype.sendClick = function(targetElement, event) {
+	'use strict';
+	var clickEvent, touch;
+
+	// On some Android devices activeElement needs to be blurred otherwise the synthetic click will have no effect (#24)
+	if (document.activeElement && document.activeElement !== targetElement) {
+		document.activeElement.blur();
+	}
+
+	touch = event.changedTouches[0];
+
+	// Synthesise a click event, with an extra attribute so it can be tracked
+	clickEvent = document.createEvent('MouseEvents');
+	clickEvent.initMouseEvent(this.determineEventType(targetElement), true, true, window, 1, touch.screenX, touch.screenY, touch.clientX, touch.clientY, false, false, false, false, 0, null);
+	clickEvent.forwardedTouchEvent = true;
+	targetElement.dispatchEvent(clickEvent);
+};
+
+FastClick.prototype.determineEventType = function(targetElement) {
+	'use strict';
+
+	//Issue #159: Android Chrome Select Box does not open with a synthetic click event
+	if (this.deviceIsAndroid && targetElement.tagName.toLowerCase() === 'select') {
+		return 'mousedown';
+	}
+
+	return 'click';
+};
+
+
+/**
+ * @param {EventTarget|Element} targetElement
+ */
+FastClick.prototype.focus = function(targetElement) {
+	'use strict';
+	var length;
+
+	// Issue #160: on iOS 7, some input elements (e.g. date datetime) throw a vague TypeError on setSelectionRange. These elements don't have an integer value for the selectionStart and selectionEnd properties, but unfortunately that can't be used for detection because accessing the properties also throws a TypeError. Just check the type instead. Filed as Apple bug #15122724.
+	if (this.deviceIsIOS && targetElement.setSelectionRange && targetElement.type.indexOf('date') !== 0 && targetElement.type !== 'time') {
+		length = targetElement.value.length;
+		targetElement.setSelectionRange(length, length);
+	} else {
+		targetElement.focus();
+	}
+};
+
+
+/**
+ * Check whether the given target element is a child of a scrollable layer and if so, set a flag on it.
+ *
+ * @param {EventTarget|Element} targetElement
+ */
+FastClick.prototype.updateScrollParent = function(targetElement) {
+	'use strict';
+	var scrollParent, parentElement;
+
+	scrollParent = targetElement.fastClickScrollParent;
+
+	// Attempt to discover whether the target element is contained within a scrollable layer. Re-check if the
+	// target element was moved to another parent.
+	if (!scrollParent || !scrollParent.contains(targetElement)) {
+		parentElement = targetElement;
+		do {
+			if (parentElement.scrollHeight > parentElement.offsetHeight) {
+				scrollParent = parentElement;
+				targetElement.fastClickScrollParent = parentElement;
+				break;
+			}
+
+			parentElement = parentElement.parentElement;
+		} while (parentElement);
+	}
+
+	// Always update the scroll top tracker if possible.
+	if (scrollParent) {
+		scrollParent.fastClickLastScrollTop = scrollParent.scrollTop;
+	}
+};
+
+
+/**
+ * @param {EventTarget} targetElement
+ * @returns {Element|EventTarget}
+ */
+FastClick.prototype.getTargetElementFromEventTarget = function(eventTarget) {
+	'use strict';
+
+	// On some older browsers (notably Safari on iOS 4.1 - see issue #56) the event target may be a text node.
+	if (eventTarget.nodeType === Node.TEXT_NODE) {
+		return eventTarget.parentNode;
+	}
+
+	return eventTarget;
+};
+
+
+/**
+ * On touch start, record the position and scroll offset.
+ *
+ * @param {Event} event
+ * @returns {boolean}
+ */
+FastClick.prototype.onTouchStart = function(event) {
+	'use strict';
+	var targetElement, touch, selection;
+
+	// Ignore multiple touches, otherwise pinch-to-zoom is prevented if both fingers are on the FastClick element (issue #111).
+	if (event.targetTouches.length > 1) {
+		return true;
+	}
+
+	targetElement = this.getTargetElementFromEventTarget(event.target);
+	touch = event.targetTouches[0];
+
+	if (this.deviceIsIOS) {
+
+		// Only trusted events will deselect text on iOS (issue #49)
+		selection = window.getSelection();
+		if (selection.rangeCount && !selection.isCollapsed) {
+			return true;
+		}
+
+		if (!this.deviceIsIOS4) {
+
+			// Weird things happen on iOS when an alert or confirm dialog is opened from a click event callback (issue #23):
+			// when the user next taps anywhere else on the page, new touchstart and touchend events are dispatched
+			// with the same identifier as the touch event that previously triggered the click that triggered the alert.
+			// Sadly, there is an issue on iOS 4 that causes some normal touch events to have the same identifier as an
+			// immediately preceeding touch event (issue #52), so this fix is unavailable on that platform.
+			if (touch.identifier === this.lastTouchIdentifier) {
+				event.preventDefault();
+				return false;
+			}
+
+			this.lastTouchIdentifier = touch.identifier;
+
+			// If the target element is a child of a scrollable layer (using -webkit-overflow-scrolling: touch) and:
+			// 1) the user does a fling scroll on the scrollable layer
+			// 2) the user stops the fling scroll with another tap
+			// then the event.target of the last 'touchend' event will be the element that was under the user's finger
+			// when the fling scroll was started, causing FastClick to send a click event to that layer - unless a check
+			// is made to ensure that a parent layer was not scrolled before sending a synthetic click (issue #42).
+			this.updateScrollParent(targetElement);
+		}
+	}
+
+	this.trackingClick = true;
+	this.trackingClickStart = event.timeStamp;
+	this.targetElement = targetElement;
+
+	this.touchStartX = touch.pageX;
+	this.touchStartY = touch.pageY;
+
+	// Prevent phantom clicks on fast double-tap (issue #36)
+	if ((event.timeStamp - this.lastClickTime) < 200) {
+		event.preventDefault();
+	}
+
+	return true;
+};
+
+
+/**
+ * Based on a touchmove event object, check whether the touch has moved past a boundary since it started.
+ *
+ * @param {Event} event
+ * @returns {boolean}
+ */
+FastClick.prototype.touchHasMoved = function(event) {
+	'use strict';
+	var touch = event.changedTouches[0], boundary = this.touchBoundary;
+
+	if (Math.abs(touch.pageX - this.touchStartX) > boundary || Math.abs(touch.pageY - this.touchStartY) > boundary) {
+		return true;
+	}
+
+	return false;
+};
+
+
+/**
+ * Update the last position.
+ *
+ * @param {Event} event
+ * @returns {boolean}
+ */
+FastClick.prototype.onTouchMove = function(event) {
+	'use strict';
+	if (!this.trackingClick) {
+		return true;
+	}
+
+	// If the touch has moved, cancel the click tracking
+	if (this.targetElement !== this.getTargetElementFromEventTarget(event.target) || this.touchHasMoved(event)) {
+		this.trackingClick = false;
+		this.targetElement = null;
+	}
+
+	return true;
+};
+
+
+/**
+ * Attempt to find the labelled control for the given label element.
+ *
+ * @param {EventTarget|HTMLLabelElement} labelElement
+ * @returns {Element|null}
+ */
+FastClick.prototype.findControl = function(labelElement) {
+	'use strict';
+
+	// Fast path for newer browsers supporting the HTML5 control attribute
+	if (labelElement.control !== undefined) {
+		return labelElement.control;
+	}
+
+	// All browsers under test that support touch events also support the HTML5 htmlFor attribute
+	if (labelElement.htmlFor) {
+		return document.getElementById(labelElement.htmlFor);
+	}
+
+	// If no for attribute exists, attempt to retrieve the first labellable descendant element
+	// the list of which is defined here: http://www.w3.org/TR/html5/forms.html#category-label
+	return labelElement.querySelector('button, input:not([type=hidden]), keygen, meter, output, progress, select, textarea');
+};
+
+
+/**
+ * On touch end, determine whether to send a click event at once.
+ *
+ * @param {Event} event
+ * @returns {boolean}
+ */
+FastClick.prototype.onTouchEnd = function(event) {
+	'use strict';
+	var forElement, trackingClickStart, targetTagName, scrollParent, touch, targetElement = this.targetElement;
+
+	if (!this.trackingClick) {
+		return true;
+	}
+
+	// Prevent phantom clicks on fast double-tap (issue #36)
+	if ((event.timeStamp - this.lastClickTime) < 200) {
+		this.cancelNextClick = true;
+		return true;
+	}
+
+	// Reset to prevent wrong click cancel on input (issue #156).
+	this.cancelNextClick = false;
+
+	this.lastClickTime = event.timeStamp;
+
+	trackingClickStart = this.trackingClickStart;
+	this.trackingClick = false;
+	this.trackingClickStart = 0;
+
+	// On some iOS devices, the targetElement supplied with the event is invalid if the layer
+	// is performing a transition or scroll, and has to be re-detected manually. Note that
+	// for this to function correctly, it must be called *after* the event target is checked!
+	// See issue #57; also filed as rdar://13048589 .
+	if (this.deviceIsIOSWithBadTarget) {
+		touch = event.changedTouches[0];
+
+		// In certain cases arguments of elementFromPoint can be negative, so prevent setting targetElement to null
+		targetElement = document.elementFromPoint(touch.pageX - window.pageXOffset, touch.pageY - window.pageYOffset) || targetElement;
+		targetElement.fastClickScrollParent = this.targetElement.fastClickScrollParent;
+	}
+
+	targetTagName = targetElement.tagName.toLowerCase();
+	if (targetTagName === 'label') {
+		forElement = this.findControl(targetElement);
+		if (forElement) {
+			this.focus(targetElement);
+			if (this.deviceIsAndroid) {
+				return false;
+			}
+
+			targetElement = forElement;
+		}
+	} else if (this.needsFocus(targetElement)) {
+
+		// Case 1: If the touch started a while ago (best guess is 100ms based on tests for issue #36) then focus will be triggered anyway. Return early and unset the target element reference so that the subsequent click will be allowed through.
+		// Case 2: Without this exception for input elements tapped when the document is contained in an iframe, then any inputted text won't be visible even though the value attribute is updated as the user types (issue #37).
+		if ((event.timeStamp - trackingClickStart) > 100 || (this.deviceIsIOS && window.top !== window && targetTagName === 'input')) {
+			this.targetElement = null;
+			return false;
+		}
+
+		this.focus(targetElement);
+
+		// Select elements need the event to go through on iOS 4, otherwise the selector menu won't open.
+		if (!this.deviceIsIOS4 || targetTagName !== 'select') {
+			this.targetElement = null;
+			event.preventDefault();
+		}
+
+		return false;
+	}
+
+	if (this.deviceIsIOS && !this.deviceIsIOS4) {
+
+		// Don't send a synthetic click event if the target element is contained within a parent layer that was scrolled
+		// and this tap is being used to stop the scrolling (usually initiated by a fling - issue #42).
+		scrollParent = targetElement.fastClickScrollParent;
+		if (scrollParent && scrollParent.fastClickLastScrollTop !== scrollParent.scrollTop) {
+			return true;
+		}
+	}
+
+	// Prevent the actual click from going though - unless the target node is marked as requiring
+	// real clicks or if it is in the whitelist in which case only non-programmatic clicks are permitted.
+	if (!this.needsClick(targetElement)) {
+		event.preventDefault();
+		this.sendClick(targetElement, event);
+	}
+
+	return false;
+};
+
+
+/**
+ * On touch cancel, stop tracking the click.
+ *
+ * @returns {void}
+ */
+FastClick.prototype.onTouchCancel = function() {
+	'use strict';
+	this.trackingClick = false;
+	this.targetElement = null;
+};
+
+
+/**
+ * Determine mouse events which should be permitted.
+ *
+ * @param {Event} event
+ * @returns {boolean}
+ */
+FastClick.prototype.onMouse = function(event) {
+	'use strict';
+
+	// If a target element was never set (because a touch event was never fired) allow the event
+	if (!this.targetElement) {
+		return true;
+	}
+
+	if (event.forwardedTouchEvent) {
+		return true;
+	}
+
+	// Programmatically generated events targeting a specific element should be permitted
+	if (!event.cancelable) {
+		return true;
+	}
+
+	// Derive and check the target element to see whether the mouse event needs to be permitted;
+	// unless explicitly enabled, prevent non-touch click events from triggering actions,
+	// to prevent ghost/doubleclicks.
+	if (!this.needsClick(this.targetElement) || this.cancelNextClick) {
+
+		// Prevent any user-added listeners declared on FastClick element from being fired.
+		if (event.stopImmediatePropagation) {
+			event.stopImmediatePropagation();
+		} else {
+
+			// Part of the hack for browsers that don't support Event#stopImmediatePropagation (e.g. Android 2)
+			event.propagationStopped = true;
+		}
+
+		// Cancel the event
+		event.stopPropagation();
+		event.preventDefault();
+
+		return false;
+	}
+
+	// If the mouse event is permitted, return true for the action to go through.
+	return true;
+};
+
+
+/**
+ * On actual clicks, determine whether this is a touch-generated click, a click action occurring
+ * naturally after a delay after a touch (which needs to be cancelled to avoid duplication), or
+ * an actual click which should be permitted.
+ *
+ * @param {Event} event
+ * @returns {boolean}
+ */
+FastClick.prototype.onClick = function(event) {
+	'use strict';
+	var permitted;
+
+	// It's possible for another FastClick-like library delivered with third-party code to fire a click event before FastClick does (issue #44). In that case, set the click-tracking flag back to false and return early. This will cause onTouchEnd to return early.
+	if (this.trackingClick) {
+		this.targetElement = null;
+		this.trackingClick = false;
+		return true;
+	}
+
+	// Very odd behaviour on iOS (issue #18): if a submit element is present inside a form and the user hits enter in the iOS simulator or clicks the Go button on the pop-up OS keyboard the a kind of 'fake' click event will be triggered with the submit-type input element as the target.
+	if (event.target.type === 'submit' && event.detail === 0) {
+		return true;
+	}
+
+	permitted = this.onMouse(event);
+
+	// Only unset targetElement if the click is not permitted. This will ensure that the check for !targetElement in onMouse fails and the browser's click doesn't go through.
+	if (!permitted) {
+		this.targetElement = null;
+	}
+
+	// If clicks are permitted, return true for the action to go through.
+	return permitted;
+};
+
+
+/**
+ * Remove all FastClick's event listeners.
+ *
+ * @returns {void}
+ */
+FastClick.prototype.destroy = function() {
+	'use strict';
+	var layer = this.layer;
+
+	if (this.deviceIsAndroid) {
+		layer.removeEventListener('mouseover', this.onMouse, true);
+		layer.removeEventListener('mousedown', this.onMouse, true);
+		layer.removeEventListener('mouseup', this.onMouse, true);
+	}
+
+	layer.removeEventListener('click', this.onClick, true);
+	layer.removeEventListener('touchstart', this.onTouchStart, false);
+	layer.removeEventListener('touchmove', this.onTouchMove, false);
+	layer.removeEventListener('touchend', this.onTouchEnd, false);
+	layer.removeEventListener('touchcancel', this.onTouchCancel, false);
+};
+
+
+/**
+ * Check whether FastClick is needed.
+ *
+ * @param {Element} layer The layer to listen on
+ */
+FastClick.notNeeded = function(layer) {
+	'use strict';
+	var metaViewport;
+	var chromeVersion;
+
+	// Devices that don't support touch don't need FastClick
+	if (typeof window.ontouchstart === 'undefined') {
+		return true;
+	}
+
+	// Chrome version - zero for other browsers
+	chromeVersion = +(/Chrome\/([0-9]+)/.exec(navigator.userAgent) || [,0])[1];
+
+	if (chromeVersion) {
+
+		if (FastClick.prototype.deviceIsAndroid) {
+			metaViewport = document.querySelector('meta[name=viewport]');
+
+			if (metaViewport) {
+				// Chrome on Android with user-scalable="no" doesn't need FastClick (issue #89)
+				if (metaViewport.content.indexOf('user-scalable=no') !== -1) {
+					return true;
+				}
+				// Chrome 32 and above with width=device-width or less don't need FastClick
+				if (chromeVersion > 31 && window.innerWidth <= window.screen.width) {
+					return true;
+				}
+			}
+
+		// Chrome desktop doesn't need FastClick (issue #15)
+		} else {
+			return true;
+		}
+	}
+
+	// IE10 with -ms-touch-action: none, which disables double-tap-to-zoom (issue #97)
+	if (layer.style.msTouchAction === 'none') {
+		return true;
+	}
+
+	return false;
+};
+
+
+/**
+ * Factory method for creating a FastClick object
+ *
+ * @param {Element} layer The layer to listen on
+ */
+FastClick.attach = function(layer) {
+	'use strict';
+	return new FastClick(layer);
+};
+
+
+if (typeof define !== 'undefined' && define.amd) {
+
+	// AMD. Register as an anonymous module.
+	define(function() {
+		'use strict';
+		return FastClick;
+	});
+} else if (typeof module !== 'undefined' && module.exports) {
+	module.exports = FastClick.attach;
+	module.exports.FastClick = FastClick;
+} else {
+	window.FastClick = FastClick;
+}
+
+});
+
+require.register("component~indexof@0.0.3", function (exports, module) {
+module.exports = function(arr, obj){
+  if (arr.indexOf) return arr.indexOf(obj);
+  for (var i = 0; i < arr.length; ++i) {
+    if (arr[i] === obj) return i;
+  }
+  return -1;
+};
+});
+
+require.register("component~classes@1.2.1", function (exports, module) {
+/**
+ * Module dependencies.
+ */
+
+var index = require('component~indexof@0.0.3');
+
+/**
+ * Whitespace regexp.
+ */
+
+var re = /\s+/;
+
+/**
+ * toString reference.
+ */
+
+var toString = Object.prototype.toString;
+
+/**
+ * Wrap `el` in a `ClassList`.
+ *
+ * @param {Element} el
+ * @return {ClassList}
+ * @api public
+ */
+
+module.exports = function(el){
+  return new ClassList(el);
+};
+
+/**
+ * Initialize a new ClassList for `el`.
+ *
+ * @param {Element} el
+ * @api private
+ */
+
+function ClassList(el) {
+  if (!el) throw new Error('A DOM element reference is required');
+  this.el = el;
+  this.list = el.classList;
+}
+
+/**
+ * Add class `name` if not already present.
+ *
+ * @param {String} name
+ * @return {ClassList}
+ * @api public
+ */
+
+ClassList.prototype.add = function(name){
+  // classList
+  if (this.list) {
+    this.list.add(name);
+    return this;
+  }
+
+  // fallback
+  var arr = this.array();
+  var i = index(arr, name);
+  if (!~i) arr.push(name);
+  this.el.className = arr.join(' ');
+  return this;
+};
+
+/**
+ * Remove class `name` when present, or
+ * pass a regular expression to remove
+ * any which match.
+ *
+ * @param {String|RegExp} name
+ * @return {ClassList}
+ * @api public
+ */
+
+ClassList.prototype.remove = function(name){
+  if ('[object RegExp]' == toString.call(name)) {
+    return this.removeMatching(name);
+  }
+
+  // classList
+  if (this.list) {
+    this.list.remove(name);
+    return this;
+  }
+
+  // fallback
+  var arr = this.array();
+  var i = index(arr, name);
+  if (~i) arr.splice(i, 1);
+  this.el.className = arr.join(' ');
+  return this;
+};
+
+/**
+ * Remove all classes matching `re`.
+ *
+ * @param {RegExp} re
+ * @return {ClassList}
+ * @api private
+ */
+
+ClassList.prototype.removeMatching = function(re){
+  var arr = this.array();
+  for (var i = 0; i < arr.length; i++) {
+    if (re.test(arr[i])) {
+      this.remove(arr[i]);
+    }
+  }
+  return this;
+};
+
+/**
+ * Toggle class `name`, can force state via `force`.
+ *
+ * For browsers that support classList, but do not support `force` yet,
+ * the mistake will be detected and corrected.
+ *
+ * @param {String} name
+ * @param {Boolean} force
+ * @return {ClassList}
+ * @api public
+ */
+
+ClassList.prototype.toggle = function(name, force){
+  // classList
+  if (this.list) {
+    if ("undefined" !== typeof force) {
+      if (force !== this.list.toggle(name, force)) {
+        this.list.toggle(name); // toggle again to correct
+      }
+    } else {
+      this.list.toggle(name);
+    }
+    return this;
+  }
+
+  // fallback
+  if ("undefined" !== typeof force) {
+    if (!force) {
+      this.remove(name);
+    } else {
+      this.add(name);
+    }
+  } else {
+    if (this.has(name)) {
+      this.remove(name);
+    } else {
+      this.add(name);
+    }
+  }
+
+  return this;
+};
+
+/**
+ * Return an array of classes.
+ *
+ * @return {Array}
+ * @api public
+ */
+
+ClassList.prototype.array = function(){
+  var str = this.el.className.replace(/^\s+|\s+$/g, '');
+  var arr = str.split(re);
+  if ('' === arr[0]) arr.shift();
+  return arr;
+};
+
+/**
+ * Check if class `name` is present.
+ *
+ * @param {String} name
+ * @return {ClassList}
+ * @api public
+ */
+
+ClassList.prototype.has =
+ClassList.prototype.contains = function(name){
+  return this.list
+    ? this.list.contains(name)
+    : !! ~index(this.array(), name);
+};
+
+});
+
+require.register("component~event@0.1.4", function (exports, module) {
+var bind = window.addEventListener ? 'addEventListener' : 'attachEvent',
+    unbind = window.removeEventListener ? 'removeEventListener' : 'detachEvent',
+    prefix = bind !== 'addEventListener' ? 'on' : '';
+
+/**
+ * Bind `el` event `type` to `fn`.
+ *
+ * @param {Element} el
+ * @param {String} type
+ * @param {Function} fn
+ * @param {Boolean} capture
+ * @return {Function}
+ * @api public
+ */
+
+exports.bind = function(el, type, fn, capture){
+  el[bind](prefix + type, fn, capture || false);
+  return fn;
+};
+
+/**
+ * Unbind `el` event `type`'s callback `fn`.
+ *
+ * @param {Element} el
+ * @param {String} type
+ * @param {Function} fn
+ * @param {Boolean} capture
+ * @return {Function}
+ * @api public
+ */
+
+exports.unbind = function(el, type, fn, capture){
+  el[unbind](prefix + type, fn, capture || false);
+  return fn;
+};
+});
+
+require.register("component~query@0.0.3", function (exports, module) {
+function one(selector, el) {
+  return el.querySelector(selector);
+}
+
+exports = module.exports = function(selector, el){
+  el = el || document;
+  return one(selector, el);
+};
+
+exports.all = function(selector, el){
+  el = el || document;
+  return el.querySelectorAll(selector);
+};
+
+exports.engine = function(obj){
+  if (!obj.one) throw new Error('.one callback required');
+  if (!obj.all) throw new Error('.all callback required');
+  one = obj.one;
+  exports.all = obj.all;
+  return exports;
+};
+
+});
+
+require.register("component~matches-selector@0.1.5", function (exports, module) {
+/**
+ * Module dependencies.
+ */
+
+var query = require('component~query@0.0.3');
+
+/**
+ * Element prototype.
+ */
+
+var proto = Element.prototype;
+
+/**
+ * Vendor function.
+ */
+
+var vendor = proto.matches
+  || proto.webkitMatchesSelector
+  || proto.mozMatchesSelector
+  || proto.msMatchesSelector
+  || proto.oMatchesSelector;
+
+/**
+ * Expose `match()`.
+ */
+
+module.exports = match;
+
+/**
+ * Match `el` to `selector`.
+ *
+ * @param {Element} el
+ * @param {String} selector
+ * @return {Boolean}
+ * @api public
+ */
+
+function match(el, selector) {
+  if (!el || el.nodeType !== 1) return false;
+  if (vendor) return vendor.call(el, selector);
+  var nodes = query.all(selector, el.parentNode);
+  for (var i = 0; i < nodes.length; ++i) {
+    if (nodes[i] == el) return true;
+  }
+  return false;
+}
+
+});
+
+require.register("component~closest@0.1.4", function (exports, module) {
+var matches = require('component~matches-selector@0.1.5')
+
+module.exports = function (element, selector, checkYoSelf, root) {
+  element = checkYoSelf ? {parentNode: element} : element
+
+  root = root || document
+
+  // Make sure `element !== document` and `element != null`
+  // otherwise we get an illegal invocation
+  while ((element = element.parentNode) && element !== document) {
+    if (matches(element, selector))
+      return element
+    // After `matches` on the edge case that
+    // the selector matches the root
+    // (when the root is not the document)
+    if (element === root)
+      return
+  }
+}
+
+});
+
+require.register("component~delegate@0.2.3", function (exports, module) {
+/**
+ * Module dependencies.
+ */
+
+var closest = require('component~closest@0.1.4')
+  , event = require('component~event@0.1.4');
+
+/**
+ * Delegate event `type` to `selector`
+ * and invoke `fn(e)`. A callback function
+ * is returned which may be passed to `.unbind()`.
+ *
+ * @param {Element} el
+ * @param {String} selector
+ * @param {String} type
+ * @param {Function} fn
+ * @param {Boolean} capture
+ * @return {Function}
+ * @api public
+ */
+
+exports.bind = function(el, selector, type, fn, capture){
+  return event.bind(el, type, function(e){
+    var target = e.target || e.srcElement;
+    e.delegateTarget = closest(target, selector, true, el);
+    if (e.delegateTarget) fn.call(el, e);
+  }, capture);
+};
+
+/**
+ * Unbind event `type`'s callback `fn`.
+ *
+ * @param {Element} el
+ * @param {String} type
+ * @param {Function} fn
+ * @param {Boolean} capture
+ * @api public
+ */
+
+exports.unbind = function(el, type, fn, capture){
+  event.unbind(el, type, fn, capture);
+};
+
+});
+
+require.register("component~events@1.0.9", function (exports, module) {
+
+/**
+ * Module dependencies.
+ */
+
+var events = require('component~event@0.1.4');
+var delegate = require('component~delegate@0.2.3');
+
+/**
+ * Expose `Events`.
+ */
+
+module.exports = Events;
+
+/**
+ * Initialize an `Events` with the given
+ * `el` object which events will be bound to,
+ * and the `obj` which will receive method calls.
+ *
+ * @param {Object} el
+ * @param {Object} obj
+ * @api public
+ */
+
+function Events(el, obj) {
+  if (!(this instanceof Events)) return new Events(el, obj);
+  if (!el) throw new Error('element required');
+  if (!obj) throw new Error('object required');
+  this.el = el;
+  this.obj = obj;
+  this._events = {};
+}
+
+/**
+ * Subscription helper.
+ */
+
+Events.prototype.sub = function(event, method, cb){
+  this._events[event] = this._events[event] || {};
+  this._events[event][method] = cb;
+};
+
+/**
+ * Bind to `event` with optional `method` name.
+ * When `method` is undefined it becomes `event`
+ * with the "on" prefix.
+ *
+ * Examples:
+ *
+ *  Direct event handling:
+ *
+ *    events.bind('click') // implies "onclick"
+ *    events.bind('click', 'remove')
+ *    events.bind('click', 'sort', 'asc')
+ *
+ *  Delegated event handling:
+ *
+ *    events.bind('click li > a')
+ *    events.bind('click li > a', 'remove')
+ *    events.bind('click a.sort-ascending', 'sort', 'asc')
+ *    events.bind('click a.sort-descending', 'sort', 'desc')
+ *
+ * @param {String} event
+ * @param {String|function} [method]
+ * @return {Function} callback
+ * @api public
+ */
+
+Events.prototype.bind = function(event, method){
+  var e = parse(event);
+  var el = this.el;
+  var obj = this.obj;
+  var name = e.name;
+  var method = method || 'on' + name;
+  var args = [].slice.call(arguments, 2);
+
+  // callback
+  function cb(){
+    var a = [].slice.call(arguments).concat(args);
+    obj[method].apply(obj, a);
+  }
+
+  // bind
+  if (e.selector) {
+    cb = delegate.bind(el, e.selector, name, cb);
+  } else {
+    events.bind(el, name, cb);
+  }
+
+  // subscription for unbinding
+  this.sub(name, method, cb);
+
+  return cb;
+};
+
+/**
+ * Unbind a single binding, all bindings for `event`,
+ * or all bindings within the manager.
+ *
+ * Examples:
+ *
+ *  Unbind direct handlers:
+ *
+ *     events.unbind('click', 'remove')
+ *     events.unbind('click')
+ *     events.unbind()
+ *
+ * Unbind delegate handlers:
+ *
+ *     events.unbind('click', 'remove')
+ *     events.unbind('click')
+ *     events.unbind()
+ *
+ * @param {String|Function} [event]
+ * @param {String|Function} [method]
+ * @api public
+ */
+
+Events.prototype.unbind = function(event, method){
+  if (0 == arguments.length) return this.unbindAll();
+  if (1 == arguments.length) return this.unbindAllOf(event);
+
+  // no bindings for this event
+  var bindings = this._events[event];
+  if (!bindings) return;
+
+  // no bindings for this method
+  var cb = bindings[method];
+  if (!cb) return;
+
+  events.unbind(this.el, event, cb);
+};
+
+/**
+ * Unbind all events.
+ *
+ * @api private
+ */
+
+Events.prototype.unbindAll = function(){
+  for (var event in this._events) {
+    this.unbindAllOf(event);
+  }
+};
+
+/**
+ * Unbind all events for `event`.
+ *
+ * @param {String} event
+ * @api private
+ */
+
+Events.prototype.unbindAllOf = function(event){
+  var bindings = this._events[event];
+  if (!bindings) return;
+
+  for (var method in bindings) {
+    this.unbind(event, method);
+  }
+};
+
+/**
+ * Parse `event`.
+ *
+ * @param {String} event
+ * @return {Object}
+ * @api private
+ */
+
+function parse(event) {
+  var parts = event.split(/ +/);
+  return {
+    name: parts.shift(),
+    selector: parts.join(' ')
+  }
+}
+
+});
+
+require.register("switchery", function (exports, module) {
+/**
+ * Switchery 0.8.1
+ * http://abpetkov.github.io/switchery/
+ *
+ * Authored by Alexander Petkov
+ * https://github.com/abpetkov
+ *
+ * Copyright 2013-2015, Alexander Petkov
+ * License: The MIT License (MIT)
+ * http://opensource.org/licenses/MIT
+ *
+ */
+
+/**
+ * External dependencies.
+ */
+
+var transitionize = require('abpetkov~transitionize@0.0.3')
+  , fastclick = require('ftlabs~fastclick@v0.6.11')
+  , classes = require('component~classes@1.2.1')
+  , events = require('component~events@1.0.9');
+
+/**
+ * Expose `Switchery`.
+ */
+
+module.exports = Switchery;
+
+/**
+ * Set Switchery default values.
+ *
+ * @api public
+ */
+
+var defaults = {
+    color             : '#64bd63'
+  , secondaryColor    : '#dfdfdf'
+  , jackColor         : '#fff'
+  , jackSecondaryColor: null
+  , className         : 'switchery'
+  , disabled          : false
+  , disabledOpacity   : 0.5
+  , speed             : '0.4s'
+  , size              : 'default'
+};
+
+/**
+ * Create Switchery object.
+ *
+ * @param {Object} element
+ * @param {Object} options
+ * @api public
+ */
+
+function Switchery(element, options) {
+  if (!(this instanceof Switchery)) return new Switchery(element, options);
+
+  this.element = element;
+  this.options = options || {};
+
+  for (var i in defaults) {
+    if (this.options[i] == null) {
+      this.options[i] = defaults[i];
+    }
+  }
+
+  if (this.element != null && this.element.type == 'checkbox') this.init();
+  if (this.isDisabled() === true) this.disable();
+}
+
+/**
+ * Hide the target element.
+ *
+ * @api private
+ */
+
+Switchery.prototype.hide = function() {
+  this.element.style.display = 'none';
+};
+
+/**
+ * Show custom switch after the target element.
+ *
+ * @api private
+ */
+
+Switchery.prototype.show = function() {
+  var switcher = this.create();
+  this.insertAfter(this.element, switcher);
+};
+
+/**
+ * Create custom switch.
+ *
+ * @returns {Object} this.switcher
+ * @api private
+ */
+
+Switchery.prototype.create = function() {
+  this.switcher = document.createElement('span');
+  this.jack = document.createElement('small');
+  this.switcher.appendChild(this.jack);
+  this.switcher.className = this.options.className;
+  this.events = events(this.switcher, this);
+
+  return this.switcher;
+};
+
+/**
+ * Insert after element after another element.
+ *
+ * @param {Object} reference
+ * @param {Object} target
+ * @api private
+ */
+
+Switchery.prototype.insertAfter = function(reference, target) {
+  reference.parentNode.insertBefore(target, reference.nextSibling);
+};
+
+/**
+ * Set switch jack proper position.
+ *
+ * @param {Boolean} clicked - we need this in order to uncheck the input when the switch is clicked
+ * @api private
+ */
+
+Switchery.prototype.setPosition = function (clicked) {
+  var checked = this.isChecked()
+    , switcher = this.switcher
+    , jack = this.jack;
+
+  if (clicked && checked) checked = false;
+  else if (clicked && !checked) checked = true;
+
+  if (checked === true) {
+    this.element.checked = true;
+
+    if (window.getComputedStyle) jack.style.left = parseInt(window.getComputedStyle(switcher).width) - parseInt(window.getComputedStyle(jack).width) + 'px';
+    else jack.style.left = parseInt(switcher.currentStyle['width']) - parseInt(jack.currentStyle['width']) + 'px';
+
+    if (this.options.color) this.colorize();
+    this.setSpeed();
+  } else {
+    jack.style.left = 0;
+    this.element.checked = false;
+    this.switcher.style.boxShadow = 'inset 0 0 0 0 ' + this.options.secondaryColor;
+    this.switcher.style.borderColor = this.options.secondaryColor;
+    this.switcher.style.backgroundColor = (this.options.secondaryColor !== defaults.secondaryColor) ? this.options.secondaryColor : '#fff';
+    this.jack.style.backgroundColor = (this.options.jackSecondaryColor !== this.options.jackColor) ? this.options.jackSecondaryColor : this.options.jackColor;
+    this.setSpeed();
+  }
+};
+
+/**
+ * Set speed.
+ *
+ * @api private
+ */
+
+Switchery.prototype.setSpeed = function() {
+  var switcherProp = {}
+    , jackProp = {
+        'background-color': this.options.speed
+      , 'left': this.options.speed.replace(/[a-z]/, '') / 2 + 's'
+    };
+
+  if (this.isChecked()) {
+    switcherProp = {
+        'border': this.options.speed
+      , 'box-shadow': this.options.speed
+      , 'background-color': this.options.speed.replace(/[a-z]/, '') * 3 + 's'
+    };
+  } else {
+    switcherProp = {
+        'border': this.options.speed
+      , 'box-shadow': this.options.speed
+    };
+  }
+
+  transitionize(this.switcher, switcherProp);
+  transitionize(this.jack, jackProp);
+};
+
+/**
+ * Set switch size.
+ *
+ * @api private
+ */
+
+Switchery.prototype.setSize = function() {
+  var small = 'switchery-small'
+    , normal = 'switchery-default'
+    , large = 'switchery-large';
+
+  switch (this.options.size) {
+    case 'small':
+      classes(this.switcher).add(small)
+      break;
+    case 'large':
+      classes(this.switcher).add(large)
+      break;
+    default:
+      classes(this.switcher).add(normal)
+      break;
+  }
+};
+
+/**
+ * Set switch color.
+ *
+ * @api private
+ */
+
+Switchery.prototype.colorize = function() {
+  var switcherHeight = this.switcher.offsetHeight / 2;
+
+  this.switcher.style.backgroundColor = this.options.color;
+  this.switcher.style.borderColor = this.options.color;
+  this.switcher.style.boxShadow = 'inset 0 0 0 ' + switcherHeight + 'px ' + this.options.color;
+  this.jack.style.backgroundColor = this.options.jackColor;
+};
+
+/**
+ * Handle the onchange event.
+ *
+ * @param {Boolean} state
+ * @api private
+ */
+
+Switchery.prototype.handleOnchange = function(state) {
+  if (document.dispatchEvent) {
+    var event = document.createEvent('HTMLEvents');
+    event.initEvent('change', true, true);
+    this.element.dispatchEvent(event);
+  } else {
+    this.element.fireEvent('onchange');
+  }
+};
+
+/**
+ * Handle the native input element state change.
+ * A `change` event must be fired in order to detect the change.
+ *
+ * @api private
+ */
+
+Switchery.prototype.handleChange = function() {
+  var self = this
+    , el = this.element;
+
+  if (el.addEventListener) {
+    el.addEventListener('change', function() {
+      self.setPosition();
+    });
+  } else {
+    el.attachEvent('onchange', function() {
+      self.setPosition();
+    });
+  }
+};
+
+/**
+ * Handle the switch click event.
+ *
+ * @api private
+ */
+
+Switchery.prototype.handleClick = function() {
+  var switcher = this.switcher;
+
+  fastclick(switcher);
+  this.events.bind('click', 'bindClick');
+};
+
+/**
+ * Attach all methods that need to happen on switcher click.
+ *
+ * @api private
+ */
+
+Switchery.prototype.bindClick = function() {
+  var parent = this.element.parentNode.tagName.toLowerCase()
+    , labelParent = (parent === 'label') ? false : true;
+
+  this.setPosition(labelParent);
+  this.handleOnchange(this.element.checked);
+};
+
+/**
+ * Mark an individual switch as already handled.
+ *
+ * @api private
+ */
+
+Switchery.prototype.markAsSwitched = function() {
+  this.element.setAttribute('data-switchery', true);
+};
+
+/**
+ * Check if an individual switch is already handled.
+ *
+ * @api private
+ */
+
+Switchery.prototype.markedAsSwitched = function() {
+  return this.element.getAttribute('data-switchery');
+};
+
+/**
+ * Initialize Switchery.
+ *
+ * @api private
+ */
+
+Switchery.prototype.init = function() {
+  this.hide();
+  this.show();
+  this.setSize();
+  this.setPosition();
+  this.markAsSwitched();
+  this.handleChange();
+  this.handleClick();
+};
+
+/**
+ * See if input is checked.
+ *
+ * @returns {Boolean}
+ * @api public
+ */
+
+Switchery.prototype.isChecked = function() {
+  return this.element.checked;
+};
+
+/**
+ * See if switcher should be disabled.
+ *
+ * @returns {Boolean}
+ * @api public
+ */
+
+Switchery.prototype.isDisabled = function() {
+  return this.options.disabled || this.element.disabled || this.element.readOnly;
+};
+
+/**
+ * Destroy all event handlers attached to the switch.
+ *
+ * @api public
+ */
+
+Switchery.prototype.destroy = function() {
+  this.events.unbind();
+};
+
+/**
+ * Enable disabled switch element.
+ *
+ * @api public
+ */
+
+Switchery.prototype.enable = function() {
+  if (!this.options.disabled) return;
+  if (this.options.disabled) this.options.disabled = false;
+  if (this.element.disabled) this.element.disabled = false;
+  if (this.element.readOnly) this.element.readOnly = false;
+  this.switcher.style.opacity = 1;
+  this.events.bind('click', 'bindClick');
+};
+
+/**
+ * Disable switch element.
+ *
+ * @api public
+ */
+
+Switchery.prototype.disable = function() {
+  if (this.options.disabled) return;
+  if (!this.options.disabled) this.options.disabled = true;
+  if (!this.element.disabled) this.element.disabled = true;
+  if (!this.element.readOnly) this.element.readOnly = true;
+  this.switcher.style.opacity = this.options.disabledOpacity;
+  this.destroy();
+};
+
+});
+
+if (typeof exports == "object") {
+  module.exports = require("switchery");
+} else if (typeof define == "function" && define.amd) {
+  define("Switchery", [], function(){ return require("switchery"); });
+} else {
+  (this || window)["Switchery"] = require("switchery");
+}
+})();

+ 29 - 0
resource/js/util/Crowi.js

@@ -196,6 +196,35 @@ export default class Crowi {
     return null;
   }
 
+  createPage(pagePath, markdown, additionalParams = {}) {
+    const params = Object.assign(additionalParams, {
+      path: pagePath,
+      body: markdown,
+    });
+    return this.apiPost('/pages.create', params)
+      .then(res => {
+        if (!res.ok) {
+          throw new Error(res.error);
+        }
+        return res.page;
+      });
+  }
+
+  updatePage(pageId, revisionId, markdown, additionalParams = {}) {
+    const params = Object.assign(additionalParams, {
+      page_id: pageId,
+      revision_id: revisionId,
+      body: markdown,
+    });
+    return this.apiPost('/pages.update', params)
+      .then(res => {
+        if (!res.ok) {
+          throw new Error(res.error);
+        }
+        return res.page;
+      });
+  }
+
   apiGet(path, params) {
     return this.apiRequest('get', path, {params: params});
   }

+ 20 - 4
resource/styles/agile-admin/inverse/colors/_apply-colors.scss

@@ -250,6 +250,24 @@ legend {
   }
 }
 
+/*
+ * Form Slider
+ */
+.admin-page {
+  span.slider {
+    background-color: #ccc;
+    &:before {
+      background-color: white;
+    }
+  }
+  input:checked+.slider {
+    background-color: #007bff;
+  }
+  input:focus+.slider {
+    box-shadow: 0 0 1px #007bff;
+  }
+}
+
 
 /*
  * Crowi sidebar
@@ -296,10 +314,8 @@ body.on-edit {
       background-color: $bodycolor;
     }
 
-    .page-form {
-      .page-editor-footer {
-        border-top-color: $border;
-      }
+    .page-editor-footer {
+      border-top-color: $border;
     }
   }
 }

+ 6 - 0
resource/styles/hackmd/style.scss

@@ -13,3 +13,9 @@
     display: none;
   }
 }
+
+.CodeMirror pre {
+  font-family: Osaka-Mono, "MS Gothic", Monaco, Menlo, Consolas, "Courier New", monospace;
+  font-size: 14px;
+  line-height: 20px;
+}

+ 8 - 0
resource/styles/scss/_admin.scss

@@ -42,6 +42,14 @@
     }
   }
 
+  .admin-notification {
+    .td-abs-center {
+      text-align: center;
+      vertical-align: middle;
+      width: 1px; // to keep the cell small
+    }
+  }
+
   // Toggle Twitter Bootstrap button class when active
   // https://jsfiddle.net/ms040m01/3/
   @mixin active-color($color, $bg-color, $border-color) {

+ 7 - 0
resource/styles/scss/_comment_growi.scss

@@ -93,7 +93,14 @@
     display: block;
   }
 
+  // display cheatsheet for comment form only
   .comment-form {
+    .editor-cheatsheet {
+        display: none;
+    }
+
+
+
     position: relative;
     margin-top: 2em;
     // user icon

+ 1 - 1
resource/styles/scss/_create-page.scss

@@ -51,7 +51,7 @@
         margin-left: 5px;
       }
 
-      #page-name-inputter {
+      #page-name-input {
         flex: 1;
         input {
           min-width: 300px; // Workaround to display placeholder.

+ 46 - 80
resource/styles/scss/_editor-attachment.scss

@@ -1,45 +1,6 @@
-.editor-container {
-  .overlay {
-    // layout
-    display: flex;
-    justify-content: center;
-    align-items: center;
-
-    position: absolute;
-    z-index: 7;  // forward than .CodeMirror-vscrollbar
-    top: 0;
-    right: 0;
-    bottom: 0;
-    left: 0;
-  }
-
-  .overlay-content {
-    padding: 0.5em;
-  }
+@import 'editor-overlay';
 
-  .page-editor-editor-container {
-    .overlay-content {
-      font-size: 2.5em;
-    }
-  }
-
-  @mixin overlay-processing-style() {
-    .overlay {
-      background: rgba(255,255,255,0.5);
-    }
-    .overlay-content {
-      padding: 0.3em;
-      background: rgba(200,200,200,0.5);
-      color: #444;
-    }
-  }
-  // add icon on cursor
-  .autoformat-markdown-table-activated .CodeMirror-cursor {
-    &:after {
-      font-family: 'FontAwesome';
-      content: '\f0ce';
-    }
-  }
+.editor-container {
 
   // for Dropzone
   .dropzone {
@@ -53,27 +14,32 @@
 
     position: relative;   // against .overlay position: absolute
 
+    @include overlay-processing-style(overlay-dropzone-active, 2.5em);
+
     // unuploadable or rejected
     &.dropzone-unuploadable, &.dropzone-rejected {
-      .overlay {
+      .overlay.overlay-dropzone-active {
         background: rgba(200,200,200,0.8);
-      }
-      .overlay-content {
-        color: #444;
+
+        .overlay-content {
+          color: #444;
+        }
       }
     }
     // uploading
     &.dropzone-uploading {
-      @include overlay-processing-style();
+      @include overlay-processing-style(overlay-dropzone-active, 2.5em);
     }
 
     // unuploadable
     &.dropzone-unuploadable {
-      .overlay-content {
-        // insert content
-        @include insertSimpleLineIcons("\e617");  // icon-exclamation
-        &:after {
-          content: "File uploading is disabled";
+      .overlay.overlay-dropzone-active {
+        .overlay-content {
+          // insert content
+          @include insertSimpleLineIcons("\e617");  // icon-exclamation
+          &:after {
+            content: "File uploading is disabled";
+          }
         }
       }
     }
@@ -81,48 +47,48 @@
     &.dropzone-uploadable {
       // accepted
       &.dropzone-accepted:not(.dropzone-rejected) {
-        .overlay {
+        .overlay.overlay-dropzone-active {
           border: 4px dashed #ccc;
-        }
-        .overlay-content {
-          // insert content
-          @include insertSimpleLineIcons("\e084");  // icon-cloud-upload
-          &:after {
-            content: "Drop here to upload";
+
+          .overlay-content {
+            // insert content
+            @include insertSimpleLineIcons("\e084");  // icon-cloud-upload
+            &:after {
+              content: "Drop here to upload";
+            }
+            // style
+            color: #666;
+            background: rgba(200,200,200,0.8);
           }
-          // style
-          color: #666;
-          background: rgba(200,200,200,0.8);
         }
       }
       // file type mismatch
-      &.dropzone-rejected:not(.dropzone-uploadablefile) .overlay-content {
-        // insert content
-        @include insertSimpleLineIcons("\e032");  // icon-picture
-        &:after {
-          content: "Only an image file is allowed";
+      &.dropzone-rejected:not(.dropzone-uploadablefile) {
+        .overlay.overlay-dropzone-active {
+          .overlay-content {
+            // insert content
+            @include insertSimpleLineIcons("\e032");  // icon-picture
+            &:after {
+              content: "Only an image file is allowed";
+            }
+          }
         }
       }
       // multiple files
-      &.dropzone-accepted.dropzone-rejected .overlay-content {
-        // insert content
-        @include insertSimpleLineIcons("\e617");  // icon-exclamation
-        &:after {
-          content: "Only 1 file is allowed";
+      &.dropzone-accepted.dropzone-rejected {
+        .overlay.overlay-dropzone-active {
+          .overlay-content {
+            // insert content
+            @include insertSimpleLineIcons("\e617");  // icon-exclamation
+            &:after {
+              content: "Only 1 file is allowed";
+            }
+          }
         }
       }
     }
   } // end of.dropzone
 
-  .textarea-editor {
-    border: none;
-    font-family: monospace;
-  }
-
-  .loading-keymap {
-    @include overlay-processing-style();
-  }
-
   .btn-open-dropzone {
     z-index: 2;
     font-size: small;

+ 79 - 0
resource/styles/scss/_editor-overlay.scss

@@ -0,0 +1,79 @@
+@mixin overlay-processing-style($additionalSelector, $contentFontSize: inherit, $contentPadding: inherit) {
+  .overlay.#{$additionalSelector} {
+    background: rgba(255, 255, 255, 0.5);
+    .overlay-content {
+      background: rgba(200, 200, 200, 0.5);
+      color: #444;
+      font-size: $contentFontSize;
+      padding: $contentPadding;
+    }
+  }
+}
+
+// overlay in .editor-container
+.editor-container {
+  .overlay {
+    // layout
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    position: absolute;
+    z-index: 7; // forward than .CodeMirror-vscrollbar
+    top: 0;
+    right: 0;
+    bottom: 0;
+    left: 0;
+    .overlay-content {
+      padding: 0.5em;
+      right: 0;
+      bottom: 0;
+    }
+  }
+
+  // loading keymap
+  @include overlay-processing-style(overlay-loading-keymap, 2.5em, 0.3em);
+
+  // cheat sheat
+  .overlay.overlay-gfm-cheatsheet {
+    justify-content: flex-end;
+    align-items: flex-end;
+
+    pointer-events: none;
+
+    .panel.gfm-cheatsheet {
+      opacity: 0.6;
+      box-shadow: unset;
+      .panel-body {
+        color: $text-muted;
+        font-family: monospace;
+        min-width: 30em;
+      }
+      ul > li {
+        list-style: none;
+      }
+    }
+
+    a.gfm-cheatsheet-modal-link {
+      pointer-events: all;
+      cursor: pointer;
+
+      opacity: 0.6;
+      color: $text-muted;
+
+      &:hover, &:focus {
+        opacity: 1;
+      }
+    }
+
+    // hide on smartphone
+    @media (max-width: $screen-xs) {
+      display: none;
+    }
+  }
+}
+
+.modal-gfm-cheatsheet .modal-body {
+  .hljs {
+    font-family: monospace;
+  }
+}

+ 82 - 142
resource/styles/scss/_on-edit.scss

@@ -1,24 +1,20 @@
+@import 'editor-overlay';
+
 body:not(.on-edit) {
-  // hide #page-form
-  #page-form {
-    display: none;
+  // hide .page-editor-footer
+  .page-editor-footer {
+    display: none !important;
   }
 }
 
 body.on-edit {
 
-  %expand-by-flex {
-    display: flex;
-    flex-direction: column;
-    flex: 1;
-  }
-
   // calculate margin
   $header-plus-footer: 2px                      // .main padding-top
                       + 42px                    // .nav height
                       + 1px                     // .page-editor-footer border-top
                       + 40px;                   // .page-editor-footer min-height
-  $editor-margin: $header-plus-footer + 22px;   // .btn-open-dropzone height
+  $editor-margin: $header-plus-footer + 26px;   // .btn-open-dropzone height
 
   // hide unnecessary elements
   .navbar.navbar-static-top,
@@ -83,7 +79,49 @@ body.on-edit {
     &,
     .content-main,
     .tab-content {
-      @extend %expand-by-flex;
+      display: flex;
+      flex-direction: column;
+      flex: 1;
+
+      .tab-pane#edit, .tab-pane#hackmd {
+        min-height: calc(100vh - #{$header-plus-footer});   // for IE11
+        height: calc(100vh - #{$header-plus-footer});
+      }
+
+      #page-editor {
+        // right(preview)
+        &,
+        &>.row,
+        .page-editor-preview-container,
+        .page-editor-preview-body {
+          min-height: calc(100vh - #{$header-plus-footer});   // for IE11
+          height: calc(100vh - #{$header-plus-footer});
+        }
+        // left(editor)
+        .page-editor-editor-container {
+          min-height: calc(100vh - #{$header-plus-footer});   // for IE11
+          height: calc(100vh - #{$header-plus-footer});
+
+          .react-codemirror2, .CodeMirror, .CodeMirror-scroll,
+          .textarea-editor {
+            height: calc(100vh - #{$editor-margin});
+          }
+
+          @media (min-width: $screen-md) {
+            padding-right: 0;
+          }
+        }
+      }
+
+      #page-editor-with-hackmd {
+        &,
+        .hackmd-preinit, #iframe-hackmd-container > iframe {
+          width: 100vw;
+          min-height: calc(100vh - #{$header-plus-footer});   // for IE11
+          height: calc(100vh - #{$header-plus-footer});
+        }
+      }
+
     }
   }
 
@@ -133,42 +171,24 @@ body.on-edit {
     min-height: 40px;
     border-top: solid 1px transparent;
 
+    .grant-selector {
+      .btn-group {
+        min-width: 150px;
+      }
+    }
     .btn-submit {
       width: 100px;
     }
   }
 
 
-  &.builtin-editor .tab-pane#edit {
-    @extend %expand-by-flex;
-
-    #page-editor {
-      // right(preview)
-      &,
-      .row,
-      .page-editor-preview-container,
-      .page-editor-preview-body {
-        min-height: calc(100vh - #{$header-plus-footer});   // for IE11
-        height: calc(100vh - #{$header-plus-footer});
-      }
-      // left(editor)
-      .page-editor-editor-container {
-        min-height: calc(100vh - #{$header-plus-footer});   // for IE11
-        height: calc(100vh - #{$header-plus-footer});
-
-        .react-codemirror2, .CodeMirror, .CodeMirror-scroll,
-        .textarea-editor {
-          height: calc(100vh - #{$editor-margin});
-        }
-      }
-    }
+  &.builtin-editor {
 
     /*****************
     * Editor styles
     *****************/
     .page-editor-editor-container {
       border-right: 1px solid transparent;
-      padding-right: 0;
       // override CodeMirror styles
       .CodeMirror {
         .cm-matchhighlight {
@@ -179,29 +199,6 @@ body.on-edit {
         }
       }
 
-      .overlay {
-        // layout
-        display: flex;
-        justify-content: center;
-        align-items: center;
-        // style
-        margin: 0 15px;
-      }
-      .overlay-content {
-        font-size: 2.5em;
-        padding: 0.5em;
-      }
-
-      @mixin overlay-processing-style() {
-        .overlay {
-          background: rgba(255,255,255,0.5);
-        }
-        .overlay-content {
-          padding: 0.3em;
-          background: rgba(200,200,200,0.5);
-          color: #444;
-        }
-      }
       // add icon on cursor
       .autoformat-markdown-table-activated .CodeMirror-cursor {
         &:after {
@@ -210,86 +207,11 @@ body.on-edit {
         }
       }
 
-      // for Dropzone
-      .dropzone {
-        @mixin insertSimpleLineIcons($code) {
-          &:before {
-            margin-right: 0.2em;
-            font-family: 'simple-line-icons';
-            content: $code;
-          }
-        }
-
-        // unuploadable or rejected
-        &.dropzone-unuploadable, &.dropzone-rejected {
-          .overlay {
-            background: rgba(200,200,200,0.8);
-          }
-          .overlay-content {
-            color: #444;
-          }
-        }
-        // uploading
-        &.dropzone-uploading {
-          @include overlay-processing-style();
-        }
-
-        // unuploadable
-        &.dropzone-unuploadable {
-          .overlay-content {
-            // insert content
-            @include insertSimpleLineIcons("\e617");  // icon-exclamation
-            &:after {
-              content: "File uploading is disabled";
-            }
-          }
-        }
-        // uploadable
-        &.dropzone-uploadable {
-          // accepted
-          &.dropzone-accepted:not(.dropzone-rejected) {
-            .overlay {
-              border: 4px dashed #ccc;
-            }
-            .overlay-content {
-              // insert content
-              @include insertSimpleLineIcons("\e084");  // icon-cloud-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) .overlay-content {
-            // insert content
-            @include insertSimpleLineIcons("\e032");  // icon-picture
-            &:after {
-              content: "Only an image file is allowed";
-            }
-          }
-          // multiple files
-          &.dropzone-accepted.dropzone-rejected .overlay-content {
-            // insert content
-            @include insertSimpleLineIcons("\e617");  // icon-exclamation
-            &:after {
-              content: "Only 1 file is allowed";
-            }
-          }
-        }
-      } // end of.dropzone
-
       .textarea-editor {
         border: none;
         font-family: monospace;
       }
 
-      .loading-keymap {
-        @include overlay-processing-style();
-      }
-
     }
     .page-editor-preview-container {
     }
@@ -334,6 +256,11 @@ body.on-edit {
       }
     }
 
+    #page-grant-selector {
+      .btn-group {
+        min-width: 150px;
+      }
+    }
   } // .builtin-editor .tab-pane#edit
 
 
@@ -342,19 +269,27 @@ body.on-edit {
       display: none;
     }
 
-    .tab-pane#hackmd {
-      @extend %expand-by-flex;
+    .hackmd-preinit, #iframe-hackmd-container > iframe {
+      border: none;
+    }
 
-      #hackmd-editor,
-      .hackmd-nopage, #iframe-hackmd {
-        width: 100vw;
-        min-height: calc(100vh - #{$header-plus-footer});   // for IE11
-        height: calc(100vh - #{$header-plus-footer});
+    .hackmd-status-label {
+      font-size: 3em;
+      color: $muted;
+    }
 
-        border: none;
+    .hackmd-start-button-container, .hackmd-resume-button-container {
+      .btn-lg .btn-label {
+        padding-top: 6px;     // for SplitButton
+        padding-bottom: 6px;  // for SplitButton
+      }
+    }
+    .hackmd-resume-button-container {
+      .dropdown-menu {
+        left: unset;
+        right: 0;
       }
     }
-
   }
 
 }
@@ -396,3 +331,8 @@ body.on-edit {
   }
 
 }
+
+// overwrite .CodeMirror-placeholder
+.CodeMirror pre.CodeMirror-placeholder {
+  color: $text-muted;
+}

+ 7 - 5
resource/styles/scss/_search.scss

@@ -1,10 +1,10 @@
 // import react-bootstrap-typeahead
 @import "~react-bootstrap-typeahead/css/Typeahead";
-
 .search-listpage-icon {
   font-size: 16px;
   color: #999;
 }
+
 .search-listpage-clear {
   display: none;
   position: absolute;
@@ -18,7 +18,6 @@
 
 .search-typeahead {
   position: relative;
-
   .search-clear {
     position: absolute;
     z-index: 2;
@@ -31,18 +30,15 @@
   }
   .rbt-menu {
     margin-top: 3px;
-
     li a span {
       .page-path {
         display: inline;
         padding: 0 4px;
         color: inherit;
       }
-
       .page-list-meta {
         font-size: .9em;
         color: #999;
-
         >span {
           margin-right: .3rem;
         }
@@ -96,6 +92,12 @@
       width: 300px;
     }
   }
+
+  table.search-help {
+    th, td {
+      border: none;
+    }
+  }
 }
 .search-sidebar {
   .search-form, .form-group, .rbt-input.form-control, .input-group {

+ 5 - 5
resource/styles/scss/theme/_override-agileadmin.scss

@@ -76,33 +76,33 @@ a {
  * Alert
  */
 .alert {
-  a {
+  a:not(.btn) {
     color: white;
   }
 
   &.alert-info {
-    a {
+    a:not(.btn) {
       &:hover, &:focus {
         color: lighten($info, 40%);
       }
     }
   }
   &.alert-success {
-    a {
+    a:not(.btn) {
       &:hover, &:focus {
         color: lighten($success, 40%);
       }
     }
   }
   &.alert-warning {
-    a {
+    a:not(.btn) {
       &:hover, &:focus {
         color: lighten($warning, 30%);
       }
     }
   }
   &.alert-danger {
-    a {
+    a:not(.btn) {
       &:hover, &:focus {
         color: lighten($danger, 30%);
       }

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 392 - 273
yarn.lock


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است