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

Merge branch 'master' into feat/mail-notification-sou

sou 7 лет назад
Родитель
Сommit
6c0e8ac98e
51 измененных файлов с 1181 добавлено и 318 удалено
  1. 6 1
      .babelrc
  2. 2 1
      .node-dev.json
  3. 15 1
      CHANGES.md
  4. 2 0
      config/env.dev.js
  5. 8 4
      config/webpack.common.js
  6. 15 0
      config/webpack.dev.js
  7. 1 1
      config/webpack.prod.js
  8. 1 0
      lib/form/admin/securityPassportLdap.js
  9. 2 1
      lib/locales/en-US/translation.json
  10. 2 1
      lib/locales/ja/translation.json
  11. 2 0
      lib/models/config.js
  12. 4 2
      lib/models/external-account.js
  13. 7 6
      lib/models/page-group-relation.js
  14. 54 35
      lib/models/page.js
  15. 10 5
      lib/models/user.js
  16. 97 0
      lib/routes/hackmd.js
  17. 5 1
      lib/routes/index.js
  18. 6 2
      lib/routes/login-passport.js
  19. 80 52
      lib/routes/page.js
  20. 10 0
      lib/service/passport.js
  21. 3 6
      lib/views/_form.html
  22. 17 6
      lib/views/admin/widget/passport/ldap.html
  23. 5 0
      lib/views/customlayout-selector/forbidden.html
  24. 41 0
      lib/views/layout-crowi/forbidden.html
  25. 25 0
      lib/views/layout-growi/forbidden.html
  26. 14 1
      lib/views/modal/create_page.html
  27. 2 2
      lib/views/modal/create_template.html
  28. 50 0
      lib/views/widget/forbidden_content.html
  29. 3 2
      lib/views/widget/not_found_content.html
  30. 1 1
      lib/views/widget/not_found_tabs.html
  31. 11 3
      lib/views/widget/page_content.html
  32. 9 2
      lib/views/widget/page_tabs.html
  33. 5 3
      package.json
  34. 61 0
      resource/js/agent-for-hackmd.js
  35. 31 0
      resource/js/app.js
  36. 1 1
      resource/js/components/Page/RevisionPath.js
  37. 39 26
      resource/js/components/PageComment/CommentForm.js
  38. 6 3
      resource/js/components/PageEditor/Editor.js
  39. 5 5
      resource/js/components/PageEditor/OptionsSelector.js
  40. 115 0
      resource/js/components/PageEditorByHackmd.jsx
  41. 43 0
      resource/js/components/PageEditorByHackmd/HackmdEditor.jsx
  42. 1 1
      resource/js/components/SlackNotification.js
  43. 13 2
      resource/js/legacy/crowi-form.js
  44. 33 20
      resource/js/legacy/crowi.js
  45. 15 0
      resource/styles/hackmd/style.scss
  46. 16 0
      resource/styles/scss/_comment_growi.scss
  47. 3 0
      resource/styles/scss/_create-page.scss
  48. 29 0
      resource/styles/scss/_editor-attachment.scss
  49. 238 114
      resource/styles/scss/_on-edit.scss
  50. 1 1
      resource/styles/scss/style.scss
  51. 16 6
      yarn.lock

+ 6 - 1
.babelrc

@@ -1,5 +1,10 @@
 {
-  "plugins": ["lodash"],
+  "plugins": [
+    "lodash",
+    ["transform-runtime", {
+      "regenerator": true
+    }]
+  ],
   "presets": [
     ["env", {
       "targets": {

+ 2 - 1
.node-dev.json

@@ -1,5 +1,6 @@
 {
   "ignore": [
-    "package.json"
+    "package.json",
+    "public/manifest.json"
   ]
 }

+ 15 - 1
CHANGES.md

@@ -1,14 +1,28 @@
 CHANGES
 ========
 
-## 3.1.12-RC
+## 3.2.0-RC
+
+* Feature: Simultaneously edit by multiple people with HackMD integration
+* Support: Upgrade libs
+    * react
+    * react-dom
+
+## 3.1.13-RC
+
+* Improvement: Add attribute mappings for email to LDAP settings
+
+## 3.1.12
 
 * Feature: Add XSS Settings
+* Feature: Notify to Slack when comment
 * Improvement: Prevent XSS in various situations
+* Improvement: Show forbidden message when the user accesses to ungranted page
 * Improvement: Add overlay styles for pasting file to comment form
 * Fix: Omit unnecessary css link
     * Introduced by 3.1.10
 * Fix: Invitation mail do not be sent
+* Fix: Edit template button on New Page modal doesn't work
 
 ## 3.1.11
 

+ 2 - 0
config/env.dev.js

@@ -1,8 +1,10 @@
 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',
   PLUGIN_NAMES_TOBE_LOADED: [
     // 'growi-plugin-lsx',
     // 'growi-plugin-pukiwiki-like-linker',

+ 8 - 4
config/webpack.common.js

@@ -27,6 +27,7 @@ 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',
       // styles
       'styles/style':                './resource/styles/scss/style.scss',
       'styles/style-presentation':   './resource/styles/scss/style-presentation.scss',
@@ -37,6 +38,8 @@ module.exports = (options) => {
       'styles/theme-mono-blue':      './resource/styles/scss/theme/mono-blue.scss',
       'styles/theme-future':         './resource/styles/scss/theme/future.scss',
       'styles/theme-blue-night':     './resource/styles/scss/theme/blue-night.scss',
+      // styles for external services
+      'styles/style-hackmd':         './resource/styles/hackmd/style.scss',
     }, options.entry || {}),  // Merge with env dependent settings
     output: Object.assign({
       path: helpers.root('public'),
@@ -51,7 +54,7 @@ module.exports = (options) => {
       'hljs': 'hljs',
     },
     resolve: {
-      extensions: ['.js', '.json'],
+      extensions: ['.js', '.jsx', '.json'],
       modules: [helpers.root('src'), helpers.root('node_modules')],
       alias: {
         '@root': helpers.root('/'),
@@ -86,12 +89,12 @@ module.exports = (options) => {
         {
           test: /\.css$/,
           use: ['style-loader', 'css-loader'],
-          exclude: [helpers.root('resource/styles/scss')]
+          exclude: [helpers.root('resource/styles')]
         },
         {
           test: /\.scss$/,
           use: ['style-loader', 'css-loader', 'sass-loader'],
-          exclude: [helpers.root('resource/styles/scss')]
+          exclude: [helpers.root('resource/styles')]
         },
         /*
          * File loader for supporting images, for example, in CSS files.
@@ -147,7 +150,8 @@ module.exports = (options) => {
           vendors: {
             test: /node_modules/,
             chunks: (chunk) => {
-              return chunk.name != null && !chunk.name.match(/legacy-presentation|ie11-polyfill/);
+              // ignore patterns
+              return chunk.name != null && !chunk.name.match(/legacy-presentation|ie11-polyfill|agent-for-hackmd/);
             },
             name: 'js/vendors',
             // minChunks: 2,

+ 15 - 0
config/webpack.dev.js

@@ -9,6 +9,7 @@ const helpers = require('./helpers');
 /*
  * Webpack Plugins
  */
+const ExtractTextPlugin = require('extract-text-webpack-plugin');
 const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
 
 /**
@@ -37,10 +38,24 @@ module.exports = require('./webpack.common')({
         ],
         include: [helpers.root('resource/styles/scss')]
       },
+      { // Dump CSS for HackMD
+        test: /\.scss$/,
+        use: ExtractTextPlugin.extract({
+          use: [
+            'css-loader',
+            'sass-loader'
+          ]
+        }),
+        include: [helpers.root('resource/styles/hackmd')]
+      },
     ],
   },
   plugins: [
 
+    new ExtractTextPlugin({
+      filename: '[name].bundle.css',
+    }),
+
     new webpack.DllReferencePlugin({
       context: helpers.root(),
       manifest: require(helpers.root('public/dll', 'manifest.json')),

+ 1 - 1
config/webpack.prod.js

@@ -40,7 +40,7 @@ module.exports = require('./webpack.common')({
             'sass-loader'
           ]
         }),
-        include: [helpers.root('resource/styles/scss')]
+        include: [helpers.root('resource/styles/scss'), helpers.root('resource/styles/hackmd')]
       }
     ]
   },

+ 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]'),

+ 2 - 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",

+ 2 - 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": "グループ検索フィルター",

+ 2 - 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,
@@ -571,6 +572,7 @@ module.exports = function(crowi) {
       env: {
         PLANTUML_URI: env.PLANTUML_URI || null,
         BLOCKDIAG_URI: env.BLOCKDIAG_URI || null,
+        HACKMD_URI: env.HACKMD_URI || null,
         MATHJAX: env.MATHJAX || null,
       },
     };

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

+ 7 - 6
lib/models/page-group-relation.js

@@ -176,13 +176,14 @@ class PageGroupRelation {
    * @returns is exists granted group(or not)
    * @memberof PageGroupRelation
    */
-  static isExistsGrantedGroupForPageAndUser(pageData, userData) {
-    var UserGroupRelation = PageGroupRelation.crowi.model('UserGroupRelation');
+  static async isExistsGrantedGroupForPageAndUser(pageData, userData) {
+    const UserGroupRelation = PageGroupRelation.crowi.model('UserGroupRelation');
 
-    return this.findByPage(pageData)
-      .then(pageRelation => {
-        return UserGroupRelation.isRelatedUserForGroup(userData, pageRelation.relatedGroup);
-      });
+    const pageRelation = await this.findByPage(pageData);
+    if (pageRelation == null) {
+      return false;
+    }
+    return await UserGroupRelation.isRelatedUserForGroup(userData, pageRelation.relatedGroup);
   }
 
   /**

+ 54 - 35
lib/models/page.js

@@ -1,3 +1,16 @@
+/**
+ * The Exception class thrown when the user has no grant to see the page
+ *
+ * @class UserHasNoGrantException
+ */
+class UserHasNoGrantException {
+  constructor(message, user) {
+    this.name = this.constructor.name;
+    this.message = message;
+    this.user = user;
+  }
+}
+
 module.exports = function(crowi) {
   var debug = require('debug')('growi:models:page')
     , mongoose = require('mongoose')
@@ -56,6 +69,8 @@ module.exports = function(crowi) {
         return JSON.stringify(data);
       }
     },
+    pageIdOnHackmd: String,
+    revisionHackmdSynced: { type: ObjectId, ref: 'Revision' },
     createdAt: { type: Date, default: Date.now },
     updatedAt: Date
   }, {
@@ -488,46 +503,33 @@ module.exports = function(crowi) {
   };
 
   // find page and check if granted user
-  pageSchema.statics.findPage = function(path, userData, revisionId, ignoreNotFound) {
-    const self = this;
+  pageSchema.statics.findPage = async function(path, userData, revisionId, ignoreNotFound) {
     const PageGroupRelation = crowi.model('PageGroupRelation');
 
-    return new Promise(function(resolve, reject) {
-      self.findOne({path: path}, function(err, pageData) {
-        if (err) {
-          return reject(err);
-        }
+    const pageData = await this.findOne({path: path});
 
-        if (pageData === null) {
-          if (ignoreNotFound) {
-            return resolve(null);
-          }
+    if (pageData == null) {
+      if (ignoreNotFound) {
+        return null;
+      }
 
-          const pageNotFoundError = new Error('Page Not Found');
-          pageNotFoundError.name = 'Crowi:Page:NotFound';
-          return reject(pageNotFoundError);
-        }
+      const pageNotFoundError = new Error('Page Not Found');
+      pageNotFoundError.name = 'Crowi:Page:NotFound';
+      throw new Error(pageNotFoundError);
+    }
 
-        if (!pageData.isGrantedFor(userData)) {
-          PageGroupRelation.isExistsGrantedGroupForPageAndUser(pageData, userData)
-            .then(isExists => {
-              if (!isExists) {
-                return reject(new Error('Page is not granted for the user')); //PAGE_GRANT_ERROR, null);
-              }
-              else {
-                // return resolve(pageData);
-                self.populatePageData(pageData, revisionId || null).then(resolve).catch(reject);
-              }
-            })
-            .catch(function(err) {
-              return reject(err);
-            });
-        }
-        else {
-          self.populatePageData(pageData, revisionId || null).then(resolve).catch(reject);
-        }
-      });
-    });
+    if (!pageData.isGrantedFor(userData)) {
+      const isRelationExists = await PageGroupRelation.isExistsGrantedGroupForPageAndUser(pageData, userData);
+      if (isRelationExists) {
+        return await this.populatePageData(pageData, revisionId || null);
+      }
+      else {
+        throw new UserHasNoGrantException('Page is not granted for the user', userData);
+      }
+    }
+    else {
+      return await this.populatePageData(pageData, revisionId || null);
+    }
   };
 
   /**
@@ -1301,6 +1303,23 @@ module.exports = function(crowi) {
       });
   };
 
+  /**
+   * associate GROWI page and HackMD page
+   * @param {Page} pageData
+   * @param {string} pageIdOnHackmd
+   */
+  pageSchema.statics.registerHackmdPage = function(pageData, pageIdOnHackmd) {
+    if (pageData.pageIdOnHackmd != null) {
+      throw new Error(`'pageIdOnHackmd' of the page '${pageData.path}' is not empty`);
+    }
+
+    pageData.pageIdOnHackmd = pageIdOnHackmd;
+    pageData.revisionHackmdSynced = pageData.revision;
+
+    // update page
+    return pageData.save();
+  };
+
   pageSchema.statics.getHistories = function() {
     // TODO
     return;

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

+ 97 - 0
lib/routes/hackmd.js

@@ -0,0 +1,97 @@
+const logger = require('@alias/logger')('growi:routes:hackmd');
+const path = require('path');
+const swig = require('swig-templates');
+const axios = require('axios');
+
+const ApiResponse = require('../util/apiResponse');
+
+module.exports = function(crowi, app) {
+  const Page = crowi.models.Page;
+
+  // 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']);
+  // generate swig template
+  let agentScriptContentTpl = undefined;
+
+
+  /**
+   * loadAgent action
+   * This should be access from HackMD and send agent script
+   *
+   * @param {object} req
+   * @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);
+
+    res.set('Content-Type', 'application/javascript');
+    res.send(script);
+  };
+
+  /**
+   * Create page on HackMD and start to integrate
+   * @param {object} req
+   * @param {object} res
+   */
+  const integrate = async function(req, res) {
+    // validate process.env.HACKMD_URI
+    const hackMdUri = process.env.HACKMD_URI;
+    if (hackMdUri == null) {
+      return res.json(ApiResponse.error('HackMD for GROWI has not been setup'));
+    }
+    // validate pageId
+    const pageId = req.body.pageId;
+    if (pageId == null) {
+      return res.json(ApiResponse.error('pageId required'));
+    }
+    // validate page
+    const page = await Page.findOne({ _id: pageId });
+    if (page == null) {
+      return res.json(ApiResponse.error(`Page('${pageId}') does not exist`));
+    }
+    if (page.pageIdOnHackmd != null) {
+      return res.json(ApiResponse.error(`'pageIdOnHackmd' of the page '${page.path}' is not empty`));
+    }
+
+    // access to HackMD and create page
+    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);
+
+      const data = {
+        pageIdOnHackmd,
+      };
+      return res.json(ApiResponse.success(data));
+    }
+    catch (err) {
+      return res.json(ApiResponse.error(err));
+    }
+  };
+
+  return {
+    loadAgent,
+    integrate,
+  };
+};

+ 5 - 1
lib/routes/index.js

@@ -1,5 +1,5 @@
 module.exports = function(crowi, app) {
-  var middleware = require('../util/middlewares')
+  const middleware = require('../util/middlewares')
     , multer    = require('multer')
     , uploads   = multer({dest: crowi.tmpDir + 'uploads'})
     , form      = require('../form')
@@ -16,6 +16,7 @@ module.exports = function(crowi, app) {
     , bookmark  = require('./bookmark')(crowi, app)
     , revision  = require('./revision')(crowi, app)
     , search    = require('./search')(crowi, app)
+    , hackmd    = require('./hackmd')(crowi, app)
     , loginRequired = middleware.loginRequired
     , accessTokenParser = middleware.accessTokenParser(crowi, app)
     , csrf      = middleware.csrfVerify(crowi, app)
@@ -200,6 +201,9 @@ module.exports = function(crowi, app) {
   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('/*/$'                   , 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);
@@ -304,7 +307,8 @@ module.exports = function(crowi, app) {
         providerId,
         userInfo.id,
         userInfo.username,
-        userInfo.name
+        userInfo.name,
+        userInfo.email,
       );
       return externalAccount;
     }

+ 80 - 52
lib/routes/page.js

@@ -1,7 +1,7 @@
 module.exports = function(crowi, app) {
   'use strict';
 
-  var debug = require('debug')('growi:routes:page')
+  const debug = require('debug')('growi:routes:page')
     , logger = require('@alias/logger')('growi:routes:page')
     , Page = crowi.model('Page')
     , User = crowi.model('User')
@@ -9,7 +9,6 @@ module.exports = function(crowi, app) {
     , config   = crowi.getConfig()
     , Revision = crowi.model('Revision')
     , Bookmark = crowi.model('Bookmark')
-    , UserGroupRelation = crowi.model('UserGroupRelation')
     , PageGroupRelation = crowi.model('PageGroupRelation')
     , UpdatePost = crowi.model('UpdatePost')
     , ApiResponse = require('../util/apiResponse')
@@ -181,6 +180,8 @@ module.exports = function(crowi, app) {
 
       if (portalPage) {
         renderVars.revision = portalPage.revision;
+        renderVars.revisionHackmdSynced = portalPage.revisionHackmdSynced;
+        renderVars.pageIdOnHackmd = portalPage.pageIdOnHackmd;
         return Revision.findRevisionList(portalPage.path, {});
       }
       else {
@@ -222,7 +223,7 @@ module.exports = function(crowi, app) {
   };
 
   actions.pageListShowForCrowiPlus = function(req, res) {
-    var path = getPathFromRequest(req);
+    let path = getPathFromRequest(req);
     // omit the slash of the last
     path = path.replace((/\/$/), '');
     // redirect
@@ -230,25 +231,25 @@ module.exports = function(crowi, app) {
   };
 
   actions.pageShowForCrowiPlus = function(req, res) {
-    var path = getPathFromRequest(req);
+    const path = getPathFromRequest(req);
 
-    var limit = 50;
-    var offset = parseInt(req.query.offset)  || 0;
-    var SEENER_THRESHOLD = 10;
+    const limit = 50;
+    const offset = parseInt(req.query.offset)  || 0;
+    const SEENER_THRESHOLD = 10;
 
     // index page
-    var pagerOptions = {
+    const pagerOptions = {
       offset: offset,
       limit: limit
     };
-    var queryOptions = {
+    const queryOptions = {
       offset: offset,
       limit: limit + 1,
       isPopulateRevisionBody: Config.isEnabledTimeline(config),
       includeDeletedPage: path.startsWith('/trash/'),
     };
 
-    var renderVars = {
+    const renderVars = {
       path: path,
       page: null,
       revision: {},
@@ -257,12 +258,13 @@ module.exports = function(crowi, app) {
       tree: [],
       pageRelatedGroup: null,
       template: null,
+      revisionHackmdSynced: null,
       slack: '',
     };
 
-    var pageTeamplate = 'customlayout-selector/page';
+    let view = 'customlayout-selector/page';
 
-    var isRedirect = false;
+    let isRedirect = false;
     Page.findPage(path, req.user, req.query.revision)
     .then(function(page) {
       debug('Page found', page._id, page.path);
@@ -280,6 +282,8 @@ 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;
 
         return Revision.findRevisionList(page.path, {})
         .then(function(tree) {
@@ -300,12 +304,12 @@ module.exports = function(crowi, app) {
           renderVars.slack = channels;
         })
         .then(function() {
-          var userPage = isUserPage(page.path);
-          var userData = null;
+          const userPage = isUserPage(page.path);
+          let userData = null;
 
           if (userPage) {
             // change template
-            pageTeamplate = 'customlayout-selector/user_page';
+            view = 'customlayout-selector/user_page';
 
             return User.findUserByUsername(User.getUsernameByPath(page.path))
             .then(function(data) {
@@ -331,18 +335,30 @@ module.exports = function(crowi, app) {
         });
       }
     })
-    // look for templates if page not exists
+    // page is not found or user is forbidden
     .catch(function(err) {
-      pageTeamplate = 'customlayout-selector/not_found';
+      let isForbidden = false;
+      if (err.name === 'UserHasNoGrantException') {
+        isForbidden = true;
+      }
 
-      return Page.findTemplate(path)
-        .then(template => {
-          if (template) {
-            template = replacePlaceholders(template, req);
-          }
+      if (isForbidden) {
+        view = 'customlayout-selector/forbidden';
+        return;
+      }
+      else {
+        view = 'customlayout-selector/not_found';
 
-          renderVars.template = template;
-        });
+        // look for templates
+        return Page.findTemplate(path)
+          .then(template => {
+            if (template) {
+              template = replacePlaceholders(template, req);
+            }
+
+            renderVars.template = template;
+          });
+      }
     })
     // get list pages
     .then(function() {
@@ -367,11 +383,10 @@ module.exports = function(crowi, app) {
             return interceptorManager.process('beforeRenderPage', req, res, renderVars);
           })
           .then(function() {
-            res.render(req.query.presentation ? 'page_presentation' : pageTeamplate, renderVars);
+            res.render(req.query.presentation ? 'page_presentation' : view, renderVars);
           })
           .catch(function(err) {
-            console.log(err);
-            debug('Error on rendering pageListShowForCrowiPlus', err);
+            logger.error('Error on rendering pageListShowForCrowiPlus', err);
           });
       }
     });
@@ -468,22 +483,28 @@ module.exports = function(crowi, app) {
     });
   };
 
-  function renderPage(pageData, req, res) {
-    // create page
+  async function renderPage(pageData, req, res, isForbidden) {
     if (!pageData) {
-      const path = getPathFromRequest(req);
-      return Page.findTemplate(path)
-        .then(template => {
-          if (template) {
-            template = replacePlaceholders(template, req);
-          }
+      let view = 'customlayout-selector/not_found';
+      let template = undefined;
 
-          return res.render('customlayout-selector/not_found', {
-            author: {},
-            page: false,
-            template,
-          });
-        });
+      // forbidden
+      if (isForbidden) {
+        view = 'customlayout-selector/forbidden';
+      }
+      else {
+        const path = getPathFromRequest(req);
+        template = await Page.findTemplate(path);
+        if (template != null) {
+          template = replacePlaceholders(template, req);
+        }
+      }
+
+      return res.render(view, {
+        author: {},
+        page: false,
+        template,
+      });
     }
 
 
@@ -491,15 +512,15 @@ module.exports = function(crowi, app) {
       return res.redirect(encodeURI(pageData.redirectTo + '?redirectFrom=' + pagePathUtil.encodePagePath(pageData.path)));
     }
 
-    var renderVars = {
+    const renderVars = {
       path: pageData.path,
       page: pageData,
       revision: pageData.revision || {},
       author: pageData.revision.author || false,
       slack: '',
     };
-    var userPage = isUserPage(pageData.path);
-    var userData = null;
+    const userPage = isUserPage(pageData.path);
+    let userData = null;
 
     Revision.findRevisionList(pageData.path, {})
     .then(function(tree) {
@@ -548,11 +569,11 @@ module.exports = function(crowi, app) {
     }).then(function() {
       return interceptorManager.process('beforeRenderPage', req, res, renderVars);
     }).then(function() {
-      var defaultPageTeamplate = 'customlayout-selector/page';
+      let view = 'customlayout-selector/page';
       if (userData) {
-        defaultPageTeamplate = 'customlayout-selector/user_page';
+        view = 'customlayout-selector/user_page';
       }
-      res.render(req.query.presentation ? 'page_presentation' : defaultPageTeamplate, renderVars);
+      res.render(req.query.presentation ? 'page_presentation' : view, renderVars);
     }).catch(function(err) {
       debug('Error: renderPage()', err);
       if (err) {
@@ -579,7 +600,14 @@ module.exports = function(crowi, app) {
       }
 
       return renderPage(page, req, res);
-    }).catch(function(err) {
+    })
+    // page is not found or the user is forbidden
+    .catch(function(err) {
+
+      let isForbidden = false;
+      if (err.name === 'UserHasNoGrantException') {
+        isForbidden = true;
+      }
 
       const normalizedPath = Page.normalizePath(path);
       if (normalizedPath !== path) {
@@ -590,7 +618,7 @@ module.exports = function(crowi, app) {
       // これ以前に定義されているはずなので、こうしてしまって問題ない。
       if (!Page.isCreatableName(path)) {
         // 削除済みページの場合 /trash 以下に移動しているので creatableName になっていないので、表示を許可
-        debug('Page is not creatable name.', path);
+        logger.warn('Page is not creatable name.', path);
         res.redirect('/');
         return ;
       }
@@ -608,9 +636,9 @@ module.exports = function(crowi, app) {
           return res.redirect(pagePathUtil.encodePagePath(path) + '/');
         }
         else {
-          var fixed = Page.fixToCreatableName(path);
+          const fixed = Page.fixToCreatableName(path);
           if (fixed !== path) {
-            debug('fixed page name', fixed);
+            logger.warn('fixed page name', fixed);
             res.redirect(pagePathUtil.encodePagePath(fixed));
             return ;
           }
@@ -622,7 +650,7 @@ module.exports = function(crowi, app) {
 
           // render editor
           debug('Catch pageShow', err);
-          return renderPage(null, req, res);
+          return renderPage(null, req, res, isForbidden);
         }
       }).catch(function(err) {
         debug('Error on rendering pageShow (redirect to portal)', err);

+ 10 - 0
lib/service/passport.js

@@ -154,6 +154,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 - 6
lib/views/_form.html

@@ -16,19 +16,16 @@
 
 <form action="/_/edit" id="page-form" method="post" class="{% if isUploadable() %}uploadable{% endif %} page-form">
 
-  <div id="page-editor">{% if pageForm.body %}{{ pageForm.body }}{% endif %}</div>
-
   <input type="hidden" id="form-body" name="pageForm[body]" value="{% if pageForm.body %}{{ pageForm.body }}{% endif %}">
   <input type="hidden" name="pageForm[path]" value="{{ path | preventXss }}">
   <input type="hidden" name="pageForm[currentRevision]" value="{{ pageForm.currentRevision|default(page.revision._id.toString()) }}">
-  <div class="page-editor-footer form-submit-group form-group form-inline
-      d-flex align-items-center justify-content-between">
+  <div class="page-editor-footer d-flex flex-row align-items-center justify-content-between">
     <div>
-      <div id="page-editor-options-selector"></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"></div>
+      <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() }}">

+ 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>
 

+ 5 - 0
lib/views/customlayout-selector/forbidden.html

@@ -0,0 +1,5 @@
+{% if !layoutType() || 'crowi' === layoutType() %}
+  {% include '../layout-crowi/forbidden.html' %}
+{% else %}
+  {% include '../layout-growi/forbidden.html' %}
+{% endif %}

+ 41 - 0
lib/views/layout-crowi/forbidden.html

@@ -0,0 +1,41 @@
+{% extends 'base/layout.html' %}
+
+{% block content_header %}
+
+  {% block content_header_before %}
+  {% endblock %}
+
+  <div class="header-wrap">
+    <header id="page-header">
+      <div>
+        <div>
+          <h1 class="title" id="revision-path"></h1>
+          <div id="revision-url" class="url-line"></div>
+        </div>
+      </div>
+
+    </header>
+  </div>
+
+  {% block content_header_after %}
+  {% endblock %}
+
+{% endblock %} {# /content_head #}
+
+
+{% block content_main_before %}
+  {% include '../widget/page_alerts.html' %}
+{% endblock %}
+
+
+{% block content_main %}
+  {% include '../widget/forbidden_content.html' %}
+{% endblock %}
+
+
+{% block content_main_after %}
+{% endblock %}
+
+
+{% block content_footer %}
+{% endblock %}

+ 25 - 0
lib/views/layout-growi/forbidden.html

@@ -0,0 +1,25 @@
+{% extends 'base/layout.html' %}
+
+
+{% block content_header %}
+  {% include 'widget/header.html' %}
+{% endblock %}
+
+
+{% block content_main_before %}
+  {% include '../widget/page_alerts.html' %}
+{% endblock %}
+
+
+{% block content_main %}
+  <div class="row">
+    <div class="col-lg-10 col-md-9">
+      {% include '../widget/forbidden_content.html' %}
+    </div> {# /.col- #}
+  </div>
+{% endblock %}
+
+{% block body_end %}
+  <div id="crowi-modals">
+  </div>
+{% endblock %}

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

@@ -46,7 +46,7 @@
 
         <div id="template-form" class="row form-horizontal m-t-15">
           <fieldset class="col-xs-12">
-            <legend>{{ t('template.modal_label.Create template under', parentPath(path)) | preventXss }}</legend>
+            <legend>{{ t('template.modal_label.Create template under', parentPath(path | preventXss)) }}</legend>
             <div class="d-flex create-page-input-container">
               <div class="create-page-input-row d-flex align-items-center">
                 <select id="template-type" class="form-control selectpicker" title="{{ t('template.option_label.select') }}">
@@ -64,6 +64,19 @@
           </fieldset>
         </div>
 
+        <script>
+          $('#template-type').on('change', function() {
+            // enable button
+            $('#link-to-template').removeClass('disabled');
+
+            // modify href
+            const value = $(this).val();
+            const pageName = (value === 'children') ? '_template' : '__template';
+            const link = '{{ page.path || path }}/' + pageName + '#edit-form';
+            $('#link-to-template').attr('href', link);
+          });
+        </script>
+
       </div><!-- /.modal-body -->
 
     </div><!-- /.modal-content -->

+ 2 - 2
lib/views/modal/create_template.html

@@ -18,7 +18,7 @@
                   <p class="help-block text-center"><small>{{ t('template.children.desc') }}</small></p>
                 </div>
                 <div class="panel-footer text-center">
-                  <a href="{% if page.path.endsWith('/') %}{{ page.path }}{% else %}{{ page.path}}/{% endif %}_template#edit-form"
+                  <a href="{% if page.path.endsWith('/') %}{{ page.path }}{% else %}{{ page.path}}/{% endif %}_template#edit"
                       class="btn btn-sm btn-primary" id="template-button-children">
                       {{ t("Edit") }}
                   </a>
@@ -33,7 +33,7 @@
                   <p class="help-block text-center"><small>{{ t('template.decendants.desc') }}</small></p>
                 </div>
                 <div class="panel-footer text-center">
-                  <a href="{% if page.path.endsWith('/') %}{{ page.path }}{% else %}{{ page.path }}/{% endif %}__template#edit-form"
+                  <a href="{% if page.path.endsWith('/') %}{{ page.path }}{% else %}{{ page.path }}/{% endif %}__template#edit"
                       class="btn btn-sm btn-primary" id="template-button-decendants">
                       {{ t("Edit") }}
                   </a>

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

@@ -0,0 +1,50 @@
+{% 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">
+      <i class="icon-ban" aria-hidden="true"></i>
+      Forbidden
+    </h2>
+  </div>
+</div>
+
+<div id="content-main" class="content-main content-main-not-found page-list"
+  data-path="{{ path | preventXss }}"
+  data-path-shortname="{{ path|path2name | preventXss }}"
+  data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
+  >
+
+  <div class="row row-alerts">
+    <div class="col-xs-12">
+        <p class="alert alert-inverse alert-grant">
+          <i class="icon-fw icon-lock" aria-hidden="true"></i> Browsing of this page is restricted
+        </p>
+    </div>
+  </div>
+
+  <ul class="nav nav-tabs hidden-print">
+    <li class="nav-main-left-tab active">
+      <a href="#revision-body" data-toggle="tab">
+        <i class="icon-notebook"></i> List
+      </a>
+    </li>
+  </ul>
+
+  <div class="tab-content">
+    {# list view #}
+    <div class="p-t-10 active tab-pane page-list-container" id="revision-body">
+      {% if pages.length == 0 %}
+        <div class="m-t-10">
+          There are no pages under <strong>{{ path }}</strong>.
+        </div>
+      {% endif  %}
+
+      {% include '../widget/page_list.html' with { pages: pages, pager: pager, viewConfig: viewConfig } %}
+    </div>
+
+  </div>
+</div>

+ 3 - 2
lib/views/widget/not_found_content.html

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

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

@@ -6,7 +6,7 @@
   </li>
 
   <li class="nav-main-left-tab">
-    <a {% if user %}href="#edit-form" data-toggle="tab"{% endif %} class="edit-button {% if not user %}edit-button-disabled{% endif %}">
+    <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('Create') }}
     </a>
   </li>

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

@@ -5,6 +5,8 @@
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
   data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
   data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
+  data-page-revision-id-hackmd-synced="{% if revisionHackmdSynced %}{{ revisionHackmdSynced.toString() }}{% endif %}"
+  data-page-id-on-hackmd="{% if pageIdOnHackmd %}{{ pageIdOnHackmd.toString() }}{% endif %}"
   data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
   data-slack-channels="{{ slack|default('') }}"
   >
@@ -28,11 +30,17 @@
       </div>
     {% endif %}
 
-    {# edit form #}
     {% if not page.isDeleted() %}
-    <div class="edit-form tab-pane {% if req.body.pageForm %}active{% endif %}" id="edit-form">
+      {# edit form #}
+      <div class="tab-pane {% if req.body.pageForm %}active{% endif %}" 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' %}
-    </div>
     {% endif %}
 
     {# raw revision history #}

+ 9 - 2
lib/views/widget/page_tabs.html

@@ -11,11 +11,18 @@
   </li>
 
   {% if !isTrashPage() %}
-  <li class="nav-main-left-tab {% if req.body.pageForm %}active{% endif %}">
-    <a {% if user %}href="#edit-form" data-toggle="tab"{% endif %} class="edit-button {% if not user %}edit-button-disabled{% endif %}">
+  <li class="nav-main-left-tab nav-tab-edit {% if req.body.pageForm %}active{% endif %}">
+    <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
+  <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 %}
 
   {#

+ 5 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.1.12-RC",
+  "version": "3.1.13-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -116,6 +116,7 @@
     "babel-core": "^6.25.0",
     "babel-loader": "^7.1.1",
     "babel-plugin-lodash": "^3.3.2",
+    "babel-plugin-transform-runtime": "^6.23.0",
     "babel-polyfill": "^6.26.0",
     "babel-preset-env": "^1.6.0",
     "babel-preset-react": "^6.24.1",
@@ -167,13 +168,14 @@
     "optimize-css-assets-webpack-plugin": "^4.0.2",
     "plantuml-encoder": "^1.2.5",
     "postcss-loader": "^2.1.3",
-    "react": "^16.2.0",
+    "react": "^16.4.1",
     "react-bootstrap": "^0.32.1",
     "react-bootstrap-typeahead": "=3.0.4",
     "react-clipboard.js": "^2.0.0",
     "react-codemirror2": "^5.0.4",
-    "react-dom": "^16.2.0",
+    "react-dom": "^16.4.1",
     "react-dropzone": "^4.2.7",
+    "react-frame-component": "^4.0.0",
     "react-i18next": "^7.6.1",
     "reveal.js": "^3.5.0",
     "sass-loader": "^7.0.1",

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

@@ -0,0 +1,61 @@
+/**
+ * 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.');

+ 31 - 0
resource/js/app.js

@@ -16,6 +16,7 @@ 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 PageEditorByHackmd from './components/PageEditorByHackmd';
 import Page             from './components/Page';
 import PageListSearch   from './components/PageListSearch';
 import PageHistory      from './components/PageHistory';
@@ -50,6 +51,8 @@ const mainContent = document.querySelector('#content-main');
 let pageId = null;
 let pageRevisionId = null;
 let pageRevisionCreatedAt = null;
+let pageRevisionIdHackmdSynced = null;
+let pageIdOnHackmd = null;
 let pagePath;
 let pageContent = '';
 let markdown = '';
@@ -59,6 +62,8 @@ 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;
   pagePath = mainContent.attributes['data-path'].value;
   slackChannels = mainContent.getAttribute('data-slack-channels');
   const rawText = document.getElementById('raw-text-original');
@@ -258,6 +263,32 @@ if (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');
 if (customCssEditorElem != null) {

+ 1 - 1
resource/js/components/Page/RevisionPath.js

@@ -110,7 +110,7 @@ export default class RevisionPath extends React.Component {
         {afterElements}
         <CopyButton buttonId="btnCopyRevisionPath" text={this.props.pagePath}
             buttonClassName="btn btn-default btn-copy" iconClassName="ti-clipboard" />
-        <a href="#edit-form" className="btn btn-default btn-edit" style={editButtonStyle}>
+        <a href="#edit" className="btn btn-default btn-edit" style={editButtonStyle}>
           <i className="icon-note"></i>
         </a>
       </span>

+ 39 - 26
resource/js/components/PageComment/CommentForm.js

@@ -210,6 +210,10 @@ export default class CommentForm extends React.Component {
     });
   }
 
+  renderControls() {
+
+  }
+
   render() {
     const crowi = this.props.crowi;
     const username = crowi.me;
@@ -219,6 +223,13 @@ export default class CommentForm extends React.Component {
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml(): ReactUtils.nl2br(comment);
     const emojiStrategy = this.props.crowi.getEmojiStrategy();
 
+    const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
+    const submitButton = (
+      <Button type="submit"bsStyle="primary" className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b">
+        Comment
+      </Button>
+    );
+
     return (
       <div>
         <form className="form page-comment-form" id="page-comment-form" onSubmit={this.postComment}>
@@ -256,33 +267,35 @@ export default class CommentForm extends React.Component {
                     }
                   </Tabs>
                 </div>
-                <div className="comment-submit d-flex">
-                  { this.state.key == 1 &&
-                    <label>
-                      <input type="checkbox" id="comment-form-is-markdown" name="isMarkdown" checked={this.state.isMarkdown} value="1" onChange={this.updateStateCheckbox} /> Markdown
-                    </label>
-                  }
-
-                  <div style={{flex: 1}}></div>{/* spacer */}
-                  { this.state.errorMessage &&
-                    <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>
-                  }
-                  { this.state.hasSlackConfig &&
-                    <div className="form-inline d-flex align-items-center">
-                      <SlackNotification
-                      crowi={this.props.crowi}
-                      pageId={this.props.pageId}
-                      pagePath={this.props.pagePath}
-                      onSlackOnChange={this.onSlackOnChange}
-                      onChannelChange={this.onChannelChange}
-                      isSlackEnabled={this.state.isSlackEnabled}
-                      slackChannels={this.state.slackChannels}
-                      />
+                <div className="comment-submit">
+                  <div className="d-flex">
+                    { this.state.key == 1 &&
+                      <label style={{flex: 1}}>
+                        <input type="checkbox" id="comment-form-is-markdown" name="isMarkdown" checked={this.state.isMarkdown} value="1" onChange={this.updateStateCheckbox} /> Markdown
+                      </label>
+                    }
+                    <span className="hidden-xs">{ this.state.errorMessage && errorMessage }</span>
+                    { this.state.hasSlackConfig &&
+                      <div className="form-inline align-self-center mr-md-2">
+                        <SlackNotification
+                          crowi={this.props.crowi}
+                          pageId={this.props.pageId}
+                          pagePath={this.props.pagePath}
+                          onSlackOnChange={this.onSlackOnChange}
+                          onChannelChange={this.onChannelChange}
+                          isSlackEnabled={this.state.isSlackEnabled}
+                          slackChannels={this.state.slackChannels}
+                        />
+                      </div>
+                    }
+                    <div className="hidden-xs">{submitButton}</div>
+                  </div>
+                  <div className="visible-xs mt-2">
+                    <div className="d-flex justify-content-end">
+                      { this.state.errorMessage && errorMessage }
+                      <div>{submitButton}</div>
                     </div>
-                  }
-                  <Button type="submit" value="Submit" bsStyle="primary" className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b">
-                    Comment
-                  </Button>
+                  </div>
                 </div>
               </div>
             </div>

+ 6 - 3
resource/js/components/PageEditor/Editor.js

@@ -251,9 +251,12 @@ export default class Editor extends AbstractEditor {
           onClick={() => {this.refs.dropzone.open()}}>
 
           <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
-          Attach files by dragging &amp; dropping,&nbsp;
-          <span className="btn-link">selecting them</span>,&nbsp;
-          or pasting from the clipboard.
+          Attach files
+          <span className="desc-long">
+            &nbsp;by dragging &amp; dropping,&nbsp;
+            <span className="btn-link">selecting them</span>,&nbsp;
+            or pasting from the clipboard.
+          </span>
         </button>
 
       </div>

+ 5 - 5
resource/js/components/PageEditor/OptionsSelector.js

@@ -121,7 +121,7 @@ export default class OptionsSelector extends React.Component {
     const bsClassName = 'form-control-dummy'; // set form-control* to shrink width
 
     return (
-      <FormGroup controlId="formControlsSelect">
+      <FormGroup controlId="formControlsSelect" className="my-0">
         <ControlLabel>Theme:</ControlLabel>
         <FormControl componentClass="select" placeholder="select" bsClass={bsClassName} className="btn-group-sm selectpicker"
             onChange={this.onChangeTheme}
@@ -149,7 +149,7 @@ export default class OptionsSelector extends React.Component {
     const bsClassName = 'form-control-dummy'; // set form-control* to shrink width
 
     return (
-      <FormGroup controlId="formControlsSelect">
+      <FormGroup controlId="formControlsSelect" className="my-0">
         <ControlLabel>Keymap:</ControlLabel>
         <FormControl componentClass="select" placeholder="select" bsClass={bsClassName} className="btn-group-sm selectpicker"
             onChange={this.onChangeKeymapMode}
@@ -164,7 +164,7 @@ export default class OptionsSelector extends React.Component {
 
   renderConfigurationDropdown() {
     return (
-      <FormGroup controlId="formControlsSelect">
+      <FormGroup controlId="formControlsSelect" className="my-0">
 
         <Dropdown dropup id="configurationDropdown" className="configuration-dropdown"
             open={this.state.isCddMenuOpened} onToggle={this.onToggleConfigurationDropdown}>
@@ -227,11 +227,11 @@ export default class OptionsSelector extends React.Component {
   }
 
   render() {
-    return <span>
+    return <div className="d-flex flex-row">
       <span className="m-l-5">{this.renderThemeSelector()}</span>
       <span className="m-l-5">{this.renderKeymapModeSelector()}</span>
       <span className="m-l-5">{this.renderConfigurationDropdown()}</span>
-    </span>;
+    </div>;
   }
 }
 

+ 115 - 0
resource/js/components/PageEditorByHackmd.jsx

@@ -0,0 +1,115 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import * as toastr from 'toastr';
+
+import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
+
+export default class PageEditorByHackmd extends React.PureComponent {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isInitializing: false,
+      pageIdOnHackmd: this.props.pageIdOnHackmd,
+    };
+
+    this.getHackmdUri = this.getHackmdUri.bind(this);
+    this.startIntegrationWithHackmd = this.startIntegrationWithHackmd.bind(this);
+
+    this.apiErrorHandler = this.apiErrorHandler.bind(this);
+  }
+
+  componentWillMount() {
+  }
+
+  getHackmdUri() {
+    const envVars = this.props.crowi.config.env;
+    return envVars.HACKMD_URI;
+  }
+
+  syncToLatestRevision() {
+
+  }
+
+  /**
+   * Start integration with HackMD
+   */
+  startIntegrationWithHackmd() {
+    const hackmdUri = this.getHackmdUri();
+
+    if (hackmdUri == null) {
+      // do nothing
+      return;
+    }
+
+    this.setState({isInitializing: true});
+
+    const params = {
+      pageId: this.props.pageId,
+    };
+    this.props.crowi.apiPost('/hackmd/integrate', params)
+      .then(res => {
+        if (!res.ok) {
+          throw new Error(res.error);
+        }
+
+        this.setState({pageIdOnHackmd: res.pageIdOnHackmd});
+      })
+      .catch(this.apiErrorHandler)
+      .then(() => {
+        this.setState({isInitializing: false});
+      });
+  }
+
+  apiErrorHandler(error) {
+    toastr.error(error.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }
+
+  render() {
+    const hackmdUri = this.getHackmdUri();
+
+    if (hackmdUri == null || this.state.pageIdOnHackmd == null) {
+      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>
+          </div>
+        </div>
+      );
+    }
+
+    return (
+      <HackmdEditor
+        markdown={this.props.markdown}
+        hackmdUri={hackmdUri}
+        pageIdOnHackmd={this.state.pageIdOnHackmd}
+      >
+      </HackmdEditor>
+    );
+  }
+}
+
+PageEditorByHackmd.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  markdown: PropTypes.string.isRequired,
+  pageId: PropTypes.string,
+  revisionId: PropTypes.string,
+  revisionIdHackmdSynced: PropTypes.string,
+  pageIdOnHackmd: PropTypes.string,
+};

+ 43 - 0
resource/js/components/PageEditorByHackmd/HackmdEditor.jsx

@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class HackmdEditor extends React.PureComponent {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+    };
+
+    this.loadHandler = this.loadHandler.bind(this);
+  }
+
+  componentWillMount() {
+  }
+
+  syncToLatestRevision() {
+
+  }
+
+  loadHandler() {
+
+  }
+
+  render() {
+    const src = `${this.props.hackmdUri}/${this.props.pageIdOnHackmd}`;
+    return (
+      <iframe id='iframe-hackmd'
+        ref='iframe'
+        src={src}
+        onLoad={this.loadHandler}
+      >
+      </iframe>
+    );
+  }
+}
+
+HackmdEditor.propTypes = {
+  markdown: PropTypes.string.isRequired,
+  hackmdUri: PropTypes.string.isRequired,
+  pageIdOnHackmd: PropTypes.string.isRequired,
+};

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

@@ -47,7 +47,7 @@ export default class SlackNotification extends React.Component {
     const formNameChannels = this.props.formName && this.props.formName + '[notify][slack][channel]';
 
     return (
-      <div className="input-group input-group-sm input-group-slack extended-setting m-r-5">
+      <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"/>

+ 13 - 2
resource/js/legacy/crowi-form.js

@@ -10,12 +10,23 @@ if (!pageId) {
   }
 }
 
-$('a[data-toggle="tab"][href="#edit-form"]').on('show.bs.tab', function() {
+$('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-form"]').on('hide.bs.tab', function() {
+$('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');
 });
 
 /**

+ 33 - 20
resource/js/legacy/crowi.js

@@ -50,7 +50,7 @@ Crowi.appendEditSectionButtons = function(parentElement) {
     // add button
     $(this).append(`
       <span class="revision-head-edit-button">
-        <a href="#edit-form" onClick="Crowi.setCaretLineData(${line})">
+        <a href="#edit" onClick="Crowi.setCaretLineData(${line})">
           <i class="icon-note"></i>
         </a>
       </span>
@@ -147,7 +147,7 @@ Crowi.handleKeyEHandler = (event) => {
     return;
   }
   // show editor
-  $('a[data-toggle="tab"][href="#edit-form"]').tab('show');
+  $('a[data-toggle="tab"][href="#edit"]').tab('show');
   event.preventDefault();
 };
 
@@ -282,7 +282,7 @@ $(function() {
     if (input2 === '') {
       prefix2 = prefix2.slice(0, -1);
     }
-    top.location.href = prefix1 + input1 + prefix2 + input2 + '#edit-form';
+    top.location.href = prefix1 + input1 + prefix2 + input2 + '#edit';
     return false;
   });
 
@@ -294,7 +294,7 @@ $(function() {
     if (name.match(/.+\/$/)) {
       name = name.substr(0, name.length - 1);
     }
-    top.location.href = pagePathUtil.encodePagePath(name) + '#edit-form';
+    top.location.href = pagePathUtil.encodePagePath(name) + '#edit';
     return false;
   });
 
@@ -504,7 +504,7 @@ $(function() {
       var template = $('#' + templateId).html();
 
       crowi.saveDraft(path, template);
-      top.location.href = `${path}#edit-form`;
+      top.location.href = `${path}#edit`;
     });
 
     /*
@@ -824,9 +824,13 @@ $(function() {
       window.location.hash = '#revision-history';
       window.history.replaceState('', 'History', '#revision-history');
     });
-    $('a[data-toggle="tab"][href="#edit-form"]').on('show.bs.tab', function() {
-      window.location.hash = '#edit-form';
-      window.history.replaceState('', 'Edit', '#edit-form');
+    $('a[data-toggle="tab"][href="#edit"]').on('show.bs.tab', function() {
+      window.location.hash = '#edit';
+      window.history.replaceState('', 'Edit', '#edit');
+    });
+    $('a[data-toggle="tab"][href="#hackmd"]').on('show.bs.tab', function() {
+      window.location.hash = '#hackmd';
+      window.history.replaceState('', 'HackMD', '#hackmd');
     });
     $('a[data-toggle="tab"][href="#revision-body"]').on('show.bs.tab', function() {
       // couln't solve https://github.com/weseek/crowi-plus/issues/119 completely -- 2017.07.03 Yuki Takei
@@ -838,20 +842,23 @@ $(function() {
     $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', function() {
       window.history.replaceState('', 'History', '#revision-history');
     });
-    $('a[data-toggle="tab"][href="#edit-form"]').on('show.bs.tab', function() {
-      window.history.replaceState('', 'Edit', '#edit-form');
+    $('a[data-toggle="tab"][href="#edit"]').on('show.bs.tab', function() {
+      window.history.replaceState('', 'Edit', '#edit');
+    });
+    $('a[data-toggle="tab"][href="#hackmd"]').on('show.bs.tab', function() {
+      window.history.replaceState('', 'HackMD', '#hackmd');
     });
     $('a[data-toggle="tab"][href="#revision-body"]').on('show.bs.tab', function() {
       window.history.replaceState('', '',  location.href.replace(location.hash, ''));
     });
-    // replace all href="#edit-form" link behaviors
-    $(document).on('click', 'a[href="#edit-form"]', function() {
-      window.location.replace('#edit-form');
+    // replace all href="#edit" link behaviors
+    $(document).on('click', 'a[href="#edit"]', function() {
+      window.location.replace('#edit');
     });
   }
 
   // focus to editor when 'shown.bs.tab' event fired
-  $('a[href="#edit-form"]').on('shown.bs.tab', function(e) {
+  $('a[href="#edit"]').on('shown.bs.tab', function(e) {
     Crowi.setCaretLineAndFocusToEditor();
   });
 });
@@ -911,12 +918,15 @@ Crowi.highlightSelectedSection = function(hash) {
 window.addEventListener('load', function(e) {
   // hash on page
   if (location.hash) {
-    if (location.hash == '#edit-form') {
-      $('a[data-toggle="tab"][href="#edit-form"]').tab('show');
+    if (location.hash === '#edit' || location.hash === '#edit-form') {
+      $('a[data-toggle="tab"][href="#edit"]').tab('show');
       // focus
       Crowi.setCaretLineAndFocusToEditor();
     }
-    if (location.hash == '#revision-history') {
+    else if (location.hash == '#hackmd') {
+      $('a[data-toggle="tab"][href="#hackmd"]').tab('show');
+    }
+    else if (location.hash == '#revision-history') {
       $('a[data-toggle="tab"][href="#revision-history"]').tab('show');
     }
   }
@@ -970,10 +980,13 @@ window.addEventListener('hashchange', function(e) {
 
   // hash on page
   if (location.hash) {
-    if (location.hash == '#edit-form') {
-      $('a[data-toggle="tab"][href="#edit-form"]').tab('show');
+    if (location.hash === '#edit') {
+      $('a[data-toggle="tab"][href="#edit"]').tab('show');
+    }
+    else if (location.hash == '#hackmd') {
+      $('a[data-toggle="tab"][href="#hackmd"]').tab('show');
     }
-    if (location.hash == '#revision-history') {
+    else if (location.hash == '#revision-history') {
       $('a[data-toggle="tab"][href="#revision-history"]').tab('show');
     }
   }

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

@@ -0,0 +1,15 @@
+.navbar-header {
+  .navbar-brand {
+    display: none;
+  }
+}
+
+.navbar-form {
+  margin-left: 15px;
+}
+
+.navbar-right {
+  .ui-new, .ui-publish {
+    display: none;
+  }
+}

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

@@ -5,6 +5,12 @@
     padding: 1em;
     margin-left: 4.5em;
     margin-bottom: 1em;
+    // screen-xs
+    @media (max-width: $screen-xs) {
+      margin-left: 3.5em;
+    }
+
+    // speech balloon
     &:before {
       border: 1em solid transparent;
       border-left-width: 0;
@@ -14,6 +20,11 @@
       top: 1.5em;
       position: absolute;
       width: 0;
+
+      // screen-xs
+      @media (max-width: $screen-xs) {
+        top: 1em;
+      }
     }
   }
 
@@ -22,6 +33,11 @@
     margin-top: 0.8em;
     width: 3em;
     height: 3em;
+    // screen-xs
+    @media (max-width: $screen-xs) {
+      width: 2em;
+      height: 2em;
+    }
   }
 
   .page-comments-row {

+ 3 - 0
resource/styles/scss/_create-page.scss

@@ -20,6 +20,9 @@
         }
         .create-page-button-container {
           margin-left: 15px;
+          .btn {
+            min-width: 105px;
+          }
         }
 
         // change layout by screen size

+ 29 - 0
resource/styles/scss/_editor-overlay.scss → resource/styles/scss/_editor-attachment.scss

@@ -122,4 +122,33 @@
   .loading-keymap {
     @include overlay-processing-style();
   }
+
+  .btn-open-dropzone {
+    z-index: 2;
+    font-size: small;
+    padding-top: 3px;
+    padding-bottom: 3px;
+    border: none;
+    border-radius: 0;
+    border-top: 1px dotted #ccc;
+    &:active {
+      box-shadow: none;
+    }
+  }
+
+}
+
+#page-editor {
+  @media (max-width: $screen-xs) {
+    .desc-long {
+      display: none;
+    }
+  }
+}
+.comment-form {
+  @media (max-width: $screen-sm) {
+    .desc-long {
+      display: none;
+    }
+  }
 }

+ 238 - 114
resource/styles/scss/_on-edit.scss

@@ -1,5 +1,25 @@
+body:not(.on-edit) {
+  // hide #page-form
+  #page-form {
+    display: none;
+  }
+}
+
 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
+
   // hide unnecessary elements
   .navbar.navbar-static-top,
   .row.row-alerts,
@@ -29,15 +49,17 @@ body.on-edit {
     display: none;
   }
 
+  // show only either Edit button or HackMD button
+  &.hackmd .nav-tab-edit {
+    display: none;
+  }
+  &:not(.hackmd) .nav-tab-hackmd {
+    display: none;
+  }
 
   /*****************
    * Expand Editor
    *****************/
-  .expand-by-flex {
-    display: flex;
-    flex-direction: column;
-    flex: 1;
-  }
   .container-fluid {
     padding-bottom: 0;
   }
@@ -60,59 +82,8 @@ body.on-edit {
 
     &,
     .content-main,
-    .tab-content,
-    .edit-form,
-    .page-form {
-      @extend .expand-by-flex;
-    }
-
-    .page-form {
-
-      // 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-sm: $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});
-            // less than smartphone
-            @media (max-width: $screen-xs) {
-              height: calc(100vh - #{$editor-margin-sm});
-            }
-          }
-        }
-      }
-
-
-      .page-editor-footer {
-        width: 100%;
-        margin: 0;
-        padding: 3px;
-        min-height: 40px;
-        border-top: solid 1px transparent;
-
-        .btn-submit {
-          width: 100px;
-        }
-      }
+    .tab-content {
+      @extend %expand-by-flex;
     }
   }
 
@@ -155,86 +126,239 @@ body.on-edit {
     }
   }
 
-  /*****************
-   * Editor styles
-   *****************/
-  .page-editor-editor-container {
-    border-right: 1px solid transparent;
-    padding-right: 0;
-    // override CodeMirror styles
-    .CodeMirror {
-      .cm-matchhighlight {
-        background-color: cyan;
+  .page-editor-footer {
+    width: 100%;
+    margin: 0;
+    padding: 3px;
+    min-height: 40px;
+    border-top: solid 1px transparent;
+
+    .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});
       }
-      .CodeMirror-selection-highlight-scrollbar {
-        background-color: darkcyan;
+      // 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});
+        }
       }
     }
 
-    .btn-open-dropzone {
-      z-index: 2;
-      font-size: small;
-      text-align: right;
-      padding-top: 3px;
-      padding-bottom: 0;
-      border: none;
-      border-radius: 0;
-      border-top: 1px dotted #ccc;
-      &:active {
-        box-shadow: none;
+    /*****************
+    * Editor styles
+    *****************/
+    .page-editor-editor-container {
+      border-right: 1px solid transparent;
+      padding-right: 0;
+      // override CodeMirror styles
+      .CodeMirror {
+        .cm-matchhighlight {
+          background-color: cyan;
+        }
+        .CodeMirror-selection-highlight-scrollbar {
+          background-color: darkcyan;
+        }
       }
-      // hide if screen size is less than smartphone
-      @media (max-width: $screen-xs) {
-        display: none;
+
+      .overlay {
+        // layout
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        // style
+        margin: 0 15px;
+      }
+      .overlay-content {
+        font-size: 2.5em;
+        padding: 0.5em;
       }
-    }
 
-  }
-  .page-editor-preview-container {
-  }
+      @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';
+        }
+      }
 
-  .page-editor-preview-body {
-    padding-top: 18px;
-    padding-right: 15px;
-    overflow-y: scroll;
-  }
+      // 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
 
-  #page-editor-options-selector {
-    label {
-      margin-right: 0.5em;
+      .textarea-editor {
+        border: none;
+        font-family: monospace;
+      }
+
+      .loading-keymap {
+        @include overlay-processing-style();
+      }
+
+    }
+    .page-editor-preview-container {
+    }
+
+    .page-editor-preview-body {
+      padding-top: 18px;
+      padding-right: 15px;
+      overflow-y: scroll;
     }
 
-    // configuration dropdown
-    .configuration-dropdown {
-      .icon-container {
-        display: inline-block;
-        width: 20px;
+    #page-editor-options-selector {
+      label {
+        margin-right: 0.5em;
       }
-      .dropdown-menu > li > a {
-        display: flex;
-        justify-content: space-between;
-        align-items: center;
 
-        .menuitem-label {
-          flex: 1;
-          margin-right: 10px;
+      // configuration dropdown
+      .configuration-dropdown {
+        .icon-container {
+          display: inline-block;
+          width: 20px;
+        }
+        .dropdown-menu > li > a {
+          display: flex;
+          justify-content: space-between;
+          align-items: center;
+
+          .menuitem-label {
+            flex: 1;
+            margin-right: 10px;
+          }
         }
       }
+
+      @media (max-width: $screen-xs-max) { // {{{ less than smartphone size
+        display: none;
+      }
+    }
+
+    #page-grant-selector {
+      .btn-group {
+        min-width: 150px;
+      }
     }
 
-    @media (max-width: $screen-xs-max) { // {{{ less than smartphone size
+  } // .builtin-editor .tab-pane#edit
+
+
+  &.hackmd {
+    #page-editor-options-selector {
       display: none;
     }
-  }
 
-  #page-grant-selector {
-    .btn-group {
-      min-width: 150px;
+    .tab-pane#hackmd {
+      @extend %expand-by-flex;
+
+      #hackmd-editor,
+      .hackmd-nopage, #iframe-hackmd {
+        width: 100vw;
+        min-height: calc(100vh - #{$header-plus-footer});   // for IE11
+        height: calc(100vh - #{$header-plus-footer});
+
+        border: none;
+      }
     }
+
   }
 
+}
 
-} // }}}
 
 /*
  * for creating portal

+ 1 - 1
resource/styles/scss/style.scss

@@ -21,7 +21,7 @@
 @import 'comment_growi';
 @import 'create-page';
 @import 'create-template';
-@import 'editor-overlay';
+@import 'editor-attachment';
 @import 'layout';
 @import 'layout_crowi';
 @import 'layout_crowi_sidebar';

+ 16 - 6
yarn.lock

@@ -1074,6 +1074,12 @@ babel-plugin-transform-regenerator@^6.22.0:
   dependencies:
     regenerator-transform "^0.10.0"
 
+babel-plugin-transform-runtime@^6.23.0:
+  version "6.23.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-transform-runtime/-/babel-plugin-transform-runtime-6.23.0.tgz#88490d446502ea9b8e7efb0fe09ec4d99479b1ee"
+  dependencies:
+    babel-runtime "^6.22.0"
+
 babel-plugin-transform-strict-mode@^6.24.1:
   version "6.24.1"
   resolved "https://registry.yarnpkg.com/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.24.1.tgz#d5faf7aa578a65bbe591cf5edae04a0c67020758"
@@ -6792,9 +6798,9 @@ react-codemirror2@^5.0.4:
   version "5.0.4"
   resolved "https://registry.yarnpkg.com/react-codemirror2/-/react-codemirror2-5.0.4.tgz#d44a2d7a63a96509ba65db9b771bd61a781b8a0d"
 
-react-dom@^16.2.0:
-  version "16.2.0"
-  resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.2.0.tgz#69003178601c0ca19b709b33a83369fe6124c044"
+react-dom@^16.4.1:
+  version "16.4.1"
+  resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.4.1.tgz#7f8b0223b3a5fbe205116c56deb85de32685dad6"
   dependencies:
     fbjs "^0.8.16"
     loose-envify "^1.1.0"
@@ -6808,6 +6814,10 @@ react-dropzone@^4.2.7:
     attr-accept "^1.0.3"
     prop-types "^15.5.7"
 
+react-frame-component@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/react-frame-component/-/react-frame-component-4.0.0.tgz#57d51cdb2da3b204cc34577349f9f5bb84a76aac"
+
 react-i18next@^7.6.1:
   version "7.6.1"
   resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-7.6.1.tgz#c61d8284f3c695893d51033f67c39e65f01212b6"
@@ -6855,9 +6865,9 @@ react-transition-group@^2.0.0, react-transition-group@^2.2.0:
     prop-types "^15.5.8"
     warning "^3.0.0"
 
-react@^16.2.0:
-  version "16.2.0"
-  resolved "https://registry.yarnpkg.com/react/-/react-16.2.0.tgz#a31bd2dab89bff65d42134fa187f24d054c273ba"
+react@^16.4.1:
+  version "16.4.1"
+  resolved "https://registry.yarnpkg.com/react/-/react-16.4.1.tgz#de51ba5764b5dbcd1f9079037b862bd26b82fe32"
   dependencies:
     fbjs "^0.8.16"
     loose-envify "^1.1.0"