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

Merge pull request #575 from weseek/feat/integrate-with-hackmd

Feat/integrate with hackmd
Yuki Takei 7 лет назад
Родитель
Сommit
164d7338a5
36 измененных файлов с 1215 добавлено и 1592 удалено
  1. 1 2
      config/env.dev.js
  2. 1 3
      lib/crowi/dev.js
  3. 36 5
      lib/models/page.js
  4. 1 2
      lib/models/revision.js
  5. 70 17
      lib/routes/hackmd.js
  6. 9 2
      lib/routes/index.js
  7. 166 209
      lib/routes/page.js
  8. 4 0
      lib/util/swigFunctions.js
  9. 11 19
      lib/views/_form.html
  10. 0 3
      lib/views/layout-growi/not_found.html
  11. 0 28
      lib/views/modal/page_name_warning.html
  12. 0 5
      lib/views/widget/forbidden_content.html
  13. 1 1
      lib/views/widget/not_found_content.html
  14. 3 4
      lib/views/widget/page_content.html
  15. 0 1
      lib/views/widget/page_modals.html
  16. 4 4
      lib/views/widget/page_tabs.html
  17. 1 0
      package.json
  18. 99 27
      resource/js/agent-for-hackmd.js
  19. 155 85
      resource/js/app.js
  20. 13 77
      resource/js/components/PageEditor.js
  21. 144 28
      resource/js/components/PageEditorByHackmd.jsx
  22. 63 12
      resource/js/components/PageEditorByHackmd/HackmdEditor.jsx
  23. 91 0
      resource/js/components/SavePageControls.jsx
  24. 47 62
      resource/js/components/SavePageControls/GrantSelector.jsx
  25. 1 3
      resource/js/components/SearchPage/SearchResult.js
  26. 16 10
      resource/js/components/SlackNotification.jsx
  27. 1 0
      resource/js/i18n.js
  28. 9 10
      resource/js/legacy/crowi-admin.js
  29. 11 605
      resource/js/legacy/crowi-form.js
  30. 2 2
      resource/js/legacy/crowi-presentation.js
  31. 150 314
      resource/js/legacy/crowi.js
  32. 29 0
      resource/js/util/Crowi.js
  33. 2 4
      resource/styles/agile-admin/inverse/colors/_apply-colors.scss
  34. 6 0
      resource/styles/hackmd/style.scss
  35. 64 48
      resource/styles/scss/_on-edit.scss
  36. 4 0
      yarn.lock

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

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

+ 36 - 5
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) {
@@ -1329,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();
   };
 

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

+ 70 - 17
lib/routes/hackmd.js

@@ -16,6 +16,8 @@ module.exports = function(crowi, app) {
 
 
   /**
+   * GET /_hackmd/load-agent
+   *
    * loadAgent action
    * This should be access from HackMD and send agent script
    *
@@ -43,15 +45,10 @@ module.exports = function(crowi, app) {
     res.send(script);
   };
 
-  /**
-   * Create page on HackMD and start to integrate
-   * @param {object} req
-   * @param {object} res
-   */
-  const integrate = async function(req, res) {
+  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
@@ -64,26 +61,80 @@ 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;
+    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) {
+      return res.json(ApiResponse.error(err));
+    }
+  };
+
+  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));
@@ -92,6 +143,8 @@ module.exports = function(crowi, app) {
 
   return {
     loadAgent,
+    validateForApi,
     integrate,
+    saveOnHackmd,
   };
 };

+ 9 - 2
lib/routes/index.js

@@ -202,13 +202,20 @@ 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.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);

+ 166 - 209
lib/routes/page.js

@@ -22,19 +22,19 @@ module.exports = function(crowi, app) {
 
   // 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;
     }
 
@@ -43,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;
 
@@ -71,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
    */
@@ -146,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,
@@ -180,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 {
@@ -259,6 +280,7 @@ module.exports = function(crowi, app) {
       pageRelatedGroup: null,
       template: null,
       revisionHackmdSynced: null,
+      hasDraftOnHackmd: false,
       slack: '',
     };
 
@@ -282,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) {
@@ -415,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: [],
@@ -455,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) {
@@ -466,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;
       });
 
@@ -583,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;
 
@@ -658,109 +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 })
-          .then((page) => {
-            // global notification
-            globalNotificationService.notifyPageEdit(page);
-            return page;
-          });
-      }
-      else {
-        // new page
-        updateOrCreate = 'create';
-        return Page.create(path, body, req.user, { grant, grantUserGroupId })
-          .then((page) => {
-            // global notification
-            globalNotificationService.notifyPageCreate(page);
-            return page;
-          });
-      }
-    }).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) {
@@ -787,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) {
@@ -803,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) {
@@ -824,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) {
@@ -841,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);
+    }
   };
 
   /**
@@ -888,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);
+    }
   };
 
   /**
@@ -952,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));
@@ -969,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'));
     }
@@ -978,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));
@@ -996,14 +954,14 @@ 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(page) {
-      var result = {page: page};
+      const result = {page: page};
       res.json(ApiResponse.success(result));
       return page;
     })
@@ -1025,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);
@@ -1047,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({}));
@@ -1060,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);
@@ -1077,8 +1035,8 @@ 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 != null);
@@ -1113,7 +1071,7 @@ module.exports = function(crowi, app) {
       })
       .then(function(data) {
         debug('Page deleted', data.path);
-        var result = {};
+        const result = {};
         result.page = data;
 
         res.json(ApiResponse.success(result));
@@ -1137,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);
@@ -1153,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));
@@ -1175,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})`));
@@ -1212,7 +1169,7 @@ module.exports = function(crowi, app) {
 
       })
       .then(function() {
-        var result = {};
+        const result = {};
         result.page = page;
 
         return res.json(ApiResponse.success(result));
@@ -1237,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) {
@@ -1259,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) {
@@ -1269,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));

+ 4 - 0
lib/util/swigFunctions.js

@@ -109,6 +109,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>

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

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

+ 4 - 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 %}
 
   {#

+ 1 - 0
package.json

@@ -167,6 +167,7 @@
     "null-loader": "^0.1.1",
     "on-headers": "^1.0.1",
     "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",

+ 99 - 27
resource/js/agent-for-hackmd.js

@@ -9,23 +9,16 @@
  *
  * @author Yuki Takei <yuki@weseek.co.jp>
  */
+import Penpal from 'penpal';
+// Penpal.debug = true;
+
+import { debounce } from 'throttle-debounce';
 
 /* 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
@@ -37,25 +30,104 @@ function insertStyle() {
   document.getElementsByTagName('head')[0].appendChild(element);
 }
 
+/**
+ * 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} document
+ */
+function setValueToCodemirror(document) {
+  // get CodeMirror instance
+  const editor = window.editor;
+  editor.doc.setValue(document);
+}
+
+/**
+ * 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);
+}
 
-insertStyle();
+function addEventListenersToCodemirror() {
+  // get CodeMirror instance
+  const codemirror = window.CodeMirror;
+  // get CodeMirror editor instance
+  const editor = window.editor;
 
-window.addEventListener('message', (event) => {
-  validateOrigin(event);
+  // 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'];
+}
 
-  const data = JSON.parse(event.data);
-  switch (data.operation) {
-    case 'getValue':
-      console.log('getValue called');
-      break;
-    case 'setValue':
-      console.log('setValue called');
-      break;
+
+/**
+ * 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;
   }
-});
 
-window.addEventListener('load', (event) => {
-  console.log('loaded');
-});
+  console.log('[HackMD] Loading GROWI agent for HackMD...');
+
+  insertStyle();
+
+  window.addEventListener('load', (event) => {
+    console.log('loaded');
+    addEventListenersToCodemirror();
+  });
+
+  const connection = Penpal.connectToParent({
+    parentOrigin: allowedOrigin,
+    // Methods child is exposing to parent
+    methods: {
+      getValue() {
+        return getValueOfCodemirror();
+      },
+      setValue(newValue) {
+        setValueToCodemirror(newValue);
+      },
+    }
+  });
+  connection.promise.then(parent => {
+    window.growi = parent;
+  });
+
+  console.log('[HackMD] GROWI agent for HackMD has successfully loaded.');
+}());
 
-console.log('[HackMD] GROWI agent for HackMD has successfully loaded.');

+ 155 - 85
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,6 +52,7 @@ let pageId = null;
 let pageRevisionId = null;
 let pageRevisionCreatedAt = null;
 let pageRevisionIdHackmdSynced = null;
+let hasDraftOnHackmd = false;
 let pageIdOnHackmd = null;
 let pagePath;
 let pageContent = '';
@@ -63,6 +64,7 @@ if (mainContent !== null) {
   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') || '';
   const rawText = document.getElementById('raw-text-original');
@@ -147,6 +149,154 @@ Object.keys(componentMappings).forEach((key) => {
   }
 });
 
+
+/**
+ * save success handler when reloading is not needed
+ * @param {object} page Page instance
+ */
+const saveWithShortcutSuccessHandler = function(page) {
+  // 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) {
+    componentInstances.pageEditor.setMarkdown(page.revision.body);
+  }
+  // set revision id to PageEditorByHackmd
+  if (componentInstances.pageEditorByHackmd != null) {
+    componentInstances.pageEditorByHackmd.setRevisionId(pageRevisionId);
+    componentInstances.pageEditorByHackmd.setMarkdown(page.revision.body);
+  }
+};
+
+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) {
+  // 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
  */
@@ -156,25 +306,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);
 }
 
@@ -199,20 +340,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) {
@@ -230,63 +357,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');

+ 13 - 77
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;
@@ -65,6 +61,15 @@ export default class PageEditor extends React.Component {
     this.renderPreview(this.state.markdown);
   }
 
+  getMarkdown() {
+    return this.state.markdown;
+  }
+
+  setMarkdown(markdown) {
+    this.setState({ markdown });
+    this.refs.editor.setValue(markdown);
+  }
+
   focusToEditor() {
     this.refs.editor.forceToFocus();
   }
@@ -103,49 +108,6 @@ export default class PageEditor extends React.Component {
     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 +255,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 });
 
@@ -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),
 };

+ 144 - 28
resource/js/components/PageEditorByHackmd.jsx

@@ -11,12 +11,17 @@ 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,
     };
 
     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 +29,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;
+      });
+  }
+
+  setMarkdown(markdown) {
+    this.setState({ markdown });
+    if (this.state.isInitialized) {
+      this.refs.hackmdEditor.setValue(markdown);
+    }
   }
 
-  syncToLatestRevision() {
+  /**
+   * 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 +76,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 +102,41 @@ export default class PageEditorByHackmd extends React.PureComponent {
       });
   }
 
+  /**
+   * Start to edit w/o any api request
+   */
+  resumeToEdit() {
+    this.setState({isInitialized: true});
+  }
+
+  /**
+   * 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 +151,70 @@ 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.props.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>
-          </div>
+        <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) {
+      content = (
+        <div>
+          <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
+          <p className="text-center">
+            <button className="btn btn-success btn-lg waves-effect waves-light" type="button"
+                onClick={() => this.resumeToEdit()}>
+              <span className="btn-label"><i className="icon-control-end"></i></span>
+              Resume to edit with HackMD
+            </button>
+          </p>
+          <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>
+          <p className="text-center">
+            <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>
+          </p>
+          <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 +222,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,
 };

+ 63 - 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,83 @@ 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;
+    });
+    connection.iframe.addEventListener('load', () => {
+      if (this.props.initializationMarkdown != null) {
+        this.setValue(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);

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

@@ -123,9 +123,7 @@ export default class SearchResult extends React.Component {
             }
           })
           .catch(err => {
-            /* eslint-disable no-console */
-            console.log(err.message);
-            /* eslint-enable */
+            console.log(err.message);   // eslint-disable-line no-console
             this.setState({errorMessageForDeleting: err.message});
             return reject();
           });

+ 16 - 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,11 +77,10 @@ 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 = {

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

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

@@ -1,4 +1,3 @@
-require('bootstrap-select');
 require('./thirdparty-js/jQuery.style.switcher');
 
 // see https://github.com/abpetkov/switchery/issues/120
@@ -31,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);
@@ -60,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);
@@ -77,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;
     }
@@ -116,7 +115,7 @@ $(function() {
   elems.forEach(function(elem) {
     const color = elem.dataset.color;
     const size = elem.dataset.size;
-    new Switchery(elem, { color, 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');

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

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

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

+ 64 - 48
resource/styles/scss/_on-edit.scss

@@ -1,20 +1,14 @@
 @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
@@ -85,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});
+        }
+      }
+
     }
   }
 
@@ -135,39 +171,18 @@ 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});
-        }
-
-        @media (min-width: $screen-md) {
-          padding-right: 0;
-        }
-      }
-    }
+  &.builtin-editor {
 
     /*****************
     * Editor styles
@@ -240,6 +255,12 @@ body.on-edit {
         min-width: 150px;
       }
     }
+
+    #page-grant-selector {
+      .btn-group {
+        min-width: 150px;
+      }
+    }
   } // .builtin-editor .tab-pane#edit
 
 
@@ -248,19 +269,14 @@ body.on-edit {
       display: none;
     }
 
-    .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;
-      }
+    .hackmd-preinit, #iframe-hackmd-container > iframe {
+      border: none;
     }
 
+    .hackmd-status-label {
+      font-size: 3em;
+      color: $muted;
+    }
   }
 
 }

+ 4 - 0
yarn.lock

@@ -6333,6 +6333,10 @@ pbkdf2@^3.0.3:
     safe-buffer "^5.0.1"
     sha.js "^2.4.8"
 
+penpal@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/penpal/-/penpal-3.0.3.tgz#6cdbd99d8c5dadb73be16d9fe6807826b0d9a715"
+
 performance-now@^0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5"