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

Merge pull request #583 from weseek/master

release v3.2.0
Yuki Takei 7 лет назад
Родитель
Сommit
f4b437389c
64 измененных файлов с 2265 добавлено и 1888 удалено
  1. 2 1
      .gitignore
  2. 3 1
      CHANGES.md
  3. 19 14
      README.md
  4. 1 2
      config/env.dev.js
  5. 4 1
      config/logger/config.dev.js
  6. 3 3
      config/webpack.common.js
  7. 1 3
      lib/crowi/dev.js
  8. 1 0
      lib/crowi/index.js
  9. 13 0
      lib/form/admin/securityPassportTwitter.js
  10. 1 0
      lib/form/index.js
  11. 12 2
      lib/locales/en-US/translation.json
  12. 11 1
      lib/locales/ja/translation.json
  13. 6 0
      lib/models/config.js
  14. 103 77
      lib/models/page.js
  15. 1 2
      lib/models/revision.js
  16. 28 0
      lib/routes/admin.js
  17. 117 18
      lib/routes/hackmd.js
  18. 13 2
      lib/routes/index.js
  19. 37 0
      lib/routes/login-passport.js
  20. 208 226
      lib/routes/page.js
  21. 47 1
      lib/service/passport.js
  22. 9 0
      lib/util/swigFunctions.js
  23. 11 24
      lib/views/_form.html
  24. 3 3
      lib/views/admin/security.html
  25. 1 1
      lib/views/admin/widget/passport/github.html
  26. 1 1
      lib/views/admin/widget/passport/google-oauth.html
  27. 115 4
      lib/views/admin/widget/passport/twitter.html
  28. 0 3
      lib/views/layout-growi/not_found.html
  29. 0 28
      lib/views/modal/page_name_warning.html
  30. 0 5
      lib/views/widget/forbidden_content.html
  31. 3 1
      lib/views/widget/not_found_content.html
  32. 4 7
      lib/views/widget/page_content.html
  33. 0 1
      lib/views/widget/page_modals.html
  34. 4 4
      lib/views/widget/page_tabs.html
  35. 2 2
      lib/views/widget/user_page_header.html
  36. 3 1
      package.json
  37. 0 61
      resource/js/agent-for-hackmd.js
  38. 294 81
      resource/js/app.js
  39. 0 69
      resource/js/components/Common/Modal.js
  40. 10 8
      resource/js/components/PageComment/CommentForm.js
  41. 17 81
      resource/js/components/PageEditor.js
  42. 1 5
      resource/js/components/PageEditor/CodeMirrorEditor.js
  43. 0 5
      resource/js/components/PageEditor/Editor.js
  44. 218 27
      resource/js/components/PageEditorByHackmd.jsx
  45. 61 12
      resource/js/components/PageEditorByHackmd/HackmdEditor.jsx
  46. 143 0
      resource/js/components/PageStatusAlert.jsx
  47. 91 0
      resource/js/components/SavePageControls.jsx
  48. 47 62
      resource/js/components/SavePageControls/GrantSelector.jsx
  49. 1 3
      resource/js/components/SearchPage/SearchResult.js
  50. 16 10
      resource/js/components/SlackNotification.jsx
  51. 149 0
      resource/js/hackmd-agent.js
  52. 42 0
      resource/js/hackmd-styles.js
  53. 1 0
      resource/js/i18n.js
  54. 9 10
      resource/js/legacy/crowi-admin.js
  55. 0 608
      resource/js/legacy/crowi-form.js
  56. 2 2
      resource/js/legacy/crowi-presentation.js
  57. 175 350
      resource/js/legacy/crowi.js
  58. 35 5
      resource/js/util/Crowi.js
  59. 2 4
      resource/styles/agile-admin/inverse/colors/_apply-colors.scss
  60. 6 0
      resource/styles/hackmd/style.scss
  61. 99 46
      resource/styles/scss/_on-edit.scss
  62. 4 0
      resource/styles/scss/_page.scss
  63. 26 0
      resource/styles/scss/_user.scss
  64. 29 0
      yarn.lock

+ 2 - 1
.gitignore

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

+ 3 - 1
CHANGES.md

@@ -4,8 +4,10 @@ CHANGES
 ## 3.2.0-RC
 
 * Feature: HackMD integration so that user can simultaneously edit with multiple people
+* Feature: Login with Twitter Account
+* Fix: The Initial scroll position is wrong when reloading the page
 
-## 3.1.14-RC
+## 3.1.14
 
 * Improvement: Show help for header search box
 * Improvement: Add Markdown Cheatsheet to Editor component

+ 19 - 14
README.md

@@ -41,7 +41,8 @@ Features
   * You can find plugins from [npm](https://www.npmjs.com/browse/keyword/growi-plugin) or [github](https://github.com/search?q=topic%3Agrowi-plugin)!
 * **Features**
   * Create hierarchical pages with markdown
-  * Support Authentication with LDAP / Active Directory 
+  * Simultaneously edit with multiple people by [HackMD(CodiMD)](https://hackmd.io/) integration
+  * Support Authentication with LDAP / Active Directory
   * Slack Incoming Webhooks Integration
   * [Miscellaneous features](https://github.com/weseek/growi/wiki/Additional-Features)
 * **[Docker Ready][dockerhub]**
@@ -156,22 +157,26 @@ Environment Variables
     * MONGO_URI: URI to connect to MongoDB.
 * **Option**
     * NODE_ENV: `production` OR `development`.
-    * PORT: Server port. default: `3000`
+    * PORT: Server port. default: `3000`.
     * ELASTICSEARCH_URI: URI to connect to Elasticearch.
     * REDIS_URI: URI to connect to Redis (use it as a session store instead of MongoDB).
-    * PLANTUML_URI: URI to connect to [PlantUML](http://plantuml.com/) server.
-    * BLOCKDIAG_URI: URI to connect to [blockdiag](http://http://blockdiag.com/) server.
     * PASSWORD_SEED: A password seed used by password hash generator.
     * SECRET_TOKEN: A secret key for verifying the integrity of signed cookies.
     * SESSION_NAME: The name of the session ID cookie to set in the response by Express. default: `connect.sid`
     * FILE_UPLOAD: `aws` (default), `local`, `none`
+* **Option to integrate with external systems**
+    * HACKMD_URI: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server.
+        * **This server must load the GROWI agent. [Here's how to prepare it](https://docs.growi.org/management-cookbook/integrate-with-hackmd).**
+    * HACKMD_URI_FOR_SERVER: URI to connect to [HackMD(CodiMD)](https://hackmd.io/) server from GROWI Express server. If not set, `HACKMD_URI` will be used.
+    * PLANTUML_URI: URI to connect to [PlantUML](http://plantuml.com/) server.
+    * BLOCKDIAG_URI: URI to connect to [blockdiag](http://http://blockdiag.com/) server.
 * **Option (Overwritable in admin page)**
-    * OAUTH_GOOGLE_CLIENT_ID: Google API client id for OAuth login
-    * OAUTH_GOOGLE_CLIENT_SECRET: Google API client secret for OAuth login
-    * OAUTH_GOOGLE_CALLBACK_URI: Google API callback URI for OAuth login (Set `https://${growi.host}/passport/google/callback`)
-    * OAUTH_GITHUB_CLIENT_ID: GitHub API client id for OAuth login
-    * OAUTH_GITHUB_CLIENT_SECRET: GitHub API client secret for OAuth login
-    * OAUTH_GITHUB_CALLBACK_URI: GitHub API callback URI for OAuth login (Set `https://${growi.host}/passport/github/callback`)
+    * OAUTH_GOOGLE_CLIENT_ID: Google API client id for OAuth login.
+    * OAUTH_GOOGLE_CLIENT_SECRET: Google API client secret for OAuth login.
+    * OAUTH_GOOGLE_CALLBACK_URI: Google API callback URI for OAuth login (Set `https://${growi.host}/passport/google/callback`).
+    * OAUTH_GITHUB_CLIENT_ID: GitHub API client id for OAuth login.
+    * OAUTH_GITHUB_CLIENT_SECRET: GitHub API client secret for OAuth login.
+    * OAUTH_GITHUB_CALLBACK_URI: GitHub API callback URI for OAuth login (Set `https://${growi.host}/passport/github/callback`).
 
 
 Documentation
@@ -191,11 +196,11 @@ For development
 ### Build and Run the app
 
 1. `clone` this repository
-1. `yarn` to install all dependencies
+2. `yarn` to install all dependencies
     * DO NOT USE `npm install`
-1. `npm run build` to build client app
-1. `npm run server` to start the dev server
-1. Access `http://0.0.0.0:3000`
+3. `npm run build` to build client app
+4. `npm run server` to start the dev server
+5. Access `http://0.0.0.0:3000`
 
 Found a Bug?
 -------------

+ 1 - 2
config/env.dev.js

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

+ 4 - 1
config/logger/config.dev.js

@@ -1,7 +1,7 @@
 module.exports = {
   default: 'info',
 
-  //// configure level for name
+  //// configure level for server
   // 'express:*': 'debug',
   // 'growi:*': 'debug',
   'growi:crowi': 'debug',
@@ -14,4 +14,7 @@ module.exports = {
   // 'growi:routes:page': 'debug',
   // 'growi-plugin:*': 'debug',
   // 'growi:InterceptorManager': 'debug',
+
+  //// configure level for client
+  'growi:app': 'debug',
 };

+ 3 - 3
config/webpack.common.js

@@ -22,12 +22,12 @@ module.exports = (options) => {
     entry: Object.assign({
       'js/app':                   './resource/js/app',
       'js/legacy':                './resource/js/legacy/crowi',
-      'js/legacy-form':           './resource/js/legacy/crowi-form',
       'js/legacy-admin':          './resource/js/legacy/crowi-admin',
       'js/legacy-presentation':   './resource/js/legacy/crowi-presentation',
       'js/plugin':                './resource/js/plugin',
       'js/ie11-polyfill':         './resource/js/ie11-polyfill',
-      'js/agent-for-hackmd':      './resource/js/agent-for-hackmd',
+      'js/hackmd-agent':          './resource/js/hackmd-agent',
+      'js/hackmd-styles':         './resource/js/hackmd-styles',
       // styles
       'styles/style':                './resource/styles/scss/style.scss',
       'styles/style-presentation':   './resource/styles/scss/style-presentation.scss',
@@ -155,7 +155,7 @@ module.exports = (options) => {
             test: /node_modules/,
             chunks: (chunk) => {
               // ignore patterns
-              return chunk.name != null && !chunk.name.match(/legacy-presentation|ie11-polyfill|agent-for-hackmd/);
+              return chunk.name != null && !chunk.name.match(/legacy-presentation|ie11-polyfill|hackmd-/);
             },
             name: 'js/vendors',
             // minChunks: 2,

+ 1 - 3
lib/crowi/dev.js

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

+ 1 - 0
lib/crowi/index.js

@@ -276,6 +276,7 @@ Crowi.prototype.setupPassport = function() {
     this.passportService.setupLdapStrategy();
     this.passportService.setupGoogleStrategy();
     this.passportService.setupGitHubStrategy();
+    this.passportService.setupTwitterStrategy(); 
   }
   catch (err) {
     logger.error(err);

+ 13 - 0
lib/form/admin/securityPassportTwitter.js

@@ -0,0 +1,13 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field
+  ;
+
+module.exports = form(
+  field('settingForm[security:passport-twitter:isEnabled]').trim().toBooleanStrict().required(),
+  field('settingForm[security:passport-twitter:consumerKey]').trim(),
+  field('settingForm[security:passport-twitter:consumerSecret]').trim(),
+  field('settingForm[security:passport-twitter:callbackUrl]').trim(),
+  field('settingForm[security:passport-twitter:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
+);

+ 1 - 0
lib/form/index.js

@@ -21,6 +21,7 @@ module.exports = {
     securityPassportLdap: require('./admin/securityPassportLdap'),
     securityPassportGoogle: require('./admin/securityPassportGoogle'),
     securityPassportGitHub: require('./admin/securityPassportGitHub'),
+    securityPassportTwitter: require('./admin/securityPassportTwitter'),
     markdown: require('./admin/markdown'),
     markdownXss: require('./admin/markdownXss'),
     customcss: require('./admin/customcss'),

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

@@ -389,13 +389,23 @@
         "name": "Facebook OAuth"
       },
       "Twitter": {
-        "name": "Twitter OAuth"
+        "name": "Twitter OAuth",
+        "register_1": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
+        "register_2": "Sign in Twitter",
+        "register_3": "Create Credentials &rightarrow; OAuth client ID &rightarrow; Select \"Web application\"",
+        "register_4": "Register your OAuth App with one of Authorized redirect URIs as <code>%s</code> (where <code>%s</code> is your hostname)",
+        "register_5": "Copy and paste your ClientID and Client Secret above"
       },
       "GitHub": {
         "name": "GitHub OAuth",
         "register_1": "Access <a href=\"%s\" target=\"_blank\">%s</a>",
         "register_2": "Register your OAuth App with \"Authorization callback URL\" as <code>%s</code> (where <code>%s</code> is your hostname)",
-        "register_3": "Copy and paste your ClientID and Client Secret above"
+        "register_3": "Copy and paste your ClientID and Client Secret above"     
+      },
+      "how_to": {
+        "google": "How to configure Google OAuth?",
+        "github": "How to configure GitHub OAuth?",
+        "twitter": "How to configure Twitter OAuth?"
       }
     }
 	},

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

@@ -406,13 +406,23 @@
         "name": "Facebook OAuth認証"
       },
       "Twitter": {
-        "name": "Twitter OAuth認証"
+        "name": "Twitter OAuth認証",
+        "register_1": "<a href=\"%s\" target=\"_blank\">%s</a>へアクセス",
+        "register_2": "Twitterにサインイン",
+        "register_3": "Create New Appをクリック &rightarrow; Application Detailsの各項目を入力",
+        "register_4": "Create your Twitter Applicationで作成",
+        "register_5": "上記フォームにクライアントIDとクライアントシークレットを入力"
       },
       "GitHub": {
         "name": "GitHub OAuth認証",
         "register_1": "<a href=\"%s\" target=\"_blank\">%s</a>へアクセス",
         "register_2": "\"Authorization callback URL\"を<code>%s</code>としてGrowiを登録 (<code>%s</code>は環境に合わせて変更してください)",
         "register_3": "上記フォームにクライアントIDとクライアントシークレットを入力"
+      },
+      "how_to": {
+        "google": "Google OAuthの設定方法",
+        "github": "GitHub OAuthの設定方法",
+        "twitter": "Twitter OAuthの設定方法"
       }
     }
   },

+ 6 - 0
lib/models/config.js

@@ -68,6 +68,7 @@ module.exports = function(crowi) {
       'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser': false,
       'security:passport-google:isEnabled' : false,
       'security:passport-github:isEnabled' : false,
+      'security:passport-twitter:isEnabled' : false,
 
       'aws:bucket'          : 'growi',
       'aws:region'          : 'ap-northeast-1',
@@ -285,6 +286,11 @@ module.exports = function(crowi) {
     return getValueForCrowiNS(config, key);
   };
 
+   configSchema.statics.isEnabledPassportTwitter = function(config) {
+    const key = 'security:passport-twitter:isEnabled';
+    return getValueForCrowiNS(config, key);
+  };
+
   configSchema.statics.isSameUsernameTreatedAsIdenticalUser = function(config, providerType) {
     const key = `security:passport-${providerType}:isSameUsernameTreatedAsIdenticalUser`;
     return getValueForCrowiNS(config, key);

+ 103 - 77
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) {
@@ -949,46 +950,25 @@ module.exports = function(crowi) {
     });
   };
 
-  pageSchema.statics.pushRevision = function(pageData, newRevision, user) {
-    var isCreate = false;
-    if (pageData.revision === undefined) {
-      debug('pushRevision on Create');
-      isCreate = true;
-    }
-
-    return new Promise(function(resolve, reject) {
-      newRevision.save(function(err, newRevision) {
-        if (err) {
-          debug('Error on saving revision', err);
-          return reject(err);
-        }
+  pageSchema.statics.pushRevision = async function(pageData, newRevision, user) {
+    await newRevision.save();
+    debug('Successfully saved new revision', newRevision);
 
-        debug('Successfully saved new revision', newRevision);
-        pageData.revision = newRevision;
-        pageData.lastUpdateUser = user;
-        pageData.updatedAt = Date.now();
-        pageData.save(function(err, data) {
-          if (err) {
-            // todo: remove new revision?
-            debug('Error on save page data (after push revision)', err);
-            return reject(err);
-          }
+    pageData.revision = newRevision;
+    pageData.lastUpdateUser = user;
+    pageData.updatedAt = Date.now();
 
-          resolve(data);
-          if (!isCreate) {
-            debug('pushRevision on Update');
-          }
-        });
-      });
-    });
+    return pageData.save();
   };
 
-  pageSchema.statics.create = function(path, body, user, options) {
+  pageSchema.statics.create = function(path, body, user, options = {}) {
     const Page = this
       , Revision = crowi.model('Revision')
       , format = options.format || 'markdown'
       , redirectTo = options.redirectTo || null
-      , grantUserGroupId = options.grantUserGroupId || null;
+      , grantUserGroupId = options.grantUserGroupId || null
+      , socketClientId = options.socketClientId || null
+      ;
 
     let grant = options.grant || GRANT_PUBLIC;
 
@@ -1032,34 +1012,47 @@ module.exports = function(crowi) {
         return Page.updateGrantUserGroup(savedPage, grant, grantUserGroupId, user);
       })
       .then(() => {
-        pageEvent.emit('create', savedPage, user);
+        if (socketClientId != null) {
+          pageEvent.emit('create', savedPage, user, socketClientId);
+        }
         return savedPage;
       });
   };
 
-  pageSchema.statics.updatePage = async function(pageData, body, user, options) {
-    var Page = this
+  pageSchema.statics.updatePage = async function(pageData, body, user, options = {}) {
+    const Page = this
       , Revision = crowi.model('Revision')
       , grant = options.grant || null
       , grantUserGroupId = options.grantUserGroupId || null
+      , isSyncRevisionToHackmd = options.isSyncRevisionToHackmd
+      , socketClientId = options.socketClientId || null
       ;
+
     // update existing page
-    var newRevision = await Revision.prepareRevision(pageData, body, user);
+    const newRevision = await Revision.prepareRevision(pageData, body, user);
 
     const revision = await Page.pushRevision(pageData, newRevision, user);
-    const savedPage = await Page.findPageByPath(revision.path).populate('revision').populate('creator');
+    let savedPage = await Page.findPageByPath(revision.path).populate('revision').populate('creator');
     if (grant != null) {
       const grantData = await Page.updateGrant(savedPage, grant, user, grantUserGroupId);
       debug('Page grant update:', grantData);
     }
-    pageEvent.emit('update', savedPage, user);
+
+    if (isSyncRevisionToHackmd) {
+      savedPage = await Page.syncRevisionToHackmd(savedPage);
+    }
+
+    if (socketClientId != null) {
+      pageEvent.emit('update', savedPage, user, socketClientId);
+    }
     return savedPage;
   };
 
-  pageSchema.statics.deletePage = function(pageData, user, options) {
-    var Page = this
+  pageSchema.statics.deletePage = async function(pageData, user, options = {}) {
+    const Page = this
       , newPath = Page.getDeletedPageName(pageData.path)
       , isTrashed = checkIfTrashed(pageData.path)
+      , socketClientId = options.socketClientId || null
       ;
 
     if (Page.isDeletableName(pageData.path)) {
@@ -1067,13 +1060,13 @@ module.exports = function(crowi) {
         return Page.completelyDeletePage(pageData, user, options);
       }
 
-      return Page.rename(pageData, newPath, user, {createRedirectPage: true})
-        .then((updatedPageData) => {
-          return Page.updatePageProperty(updatedPageData, {status: STATUS_DELETED, lastUpdateUser: user});
-        })
-        .then(() => {
-          return pageData;
-        });
+      let updatedPageData = await Page.rename(pageData, newPath, user, {createRedirectPage: true});
+      await Page.updatePageProperty(updatedPageData, {status: STATUS_DELETED, lastUpdateUser: user});
+
+      if (socketClientId != null) {
+        pageEvent.emit('delete', updatedPageData, user, socketClientId);
+      }
+      return updatedPageData;
     }
     else {
       return Promise.reject('Page is not deletable.');
@@ -1085,11 +1078,11 @@ module.exports = function(crowi) {
   };
 
   pageSchema.statics.deletePageRecursively = function(pageData, user, options) {
-    var Page = this
+    const Page = this
       , path = pageData.path
-      , options = options || {}
-      , isTrashed = checkIfTrashed(pageData.path);
+      , isTrashed = checkIfTrashed(pageData.path)
       ;
+    options = options || {};
 
     if (isTrashed) {
       return Page.completelyDeletePageRecursively(pageData, user, options);
@@ -1108,7 +1101,7 @@ module.exports = function(crowi) {
   };
 
   pageSchema.statics.revertDeletedPage = function(pageData, user, options) {
-    var Page = this
+    const Page = this
       , newPath = Page.getRevertDeletedPageName(pageData.path)
       ;
 
@@ -1122,7 +1115,7 @@ module.exports = function(crowi) {
           throw new Error('The new page of to revert is exists and the redirect path of the page is not the deleted page.');
         }
 
-        return Page.completelyDeletePage(originPageData);
+        return Page.completelyDeletePage(originPageData, options);
       }).then(function(done) {
         return Page.updatePageProperty(pageData, {status: STATUS_PUBLISHED, lastUpdateUser: user});
       }).then(function(done) {
@@ -1137,11 +1130,11 @@ module.exports = function(crowi) {
     });
   };
 
-  pageSchema.statics.revertDeletedPageRecursively = function(pageData, user, options) {
-    var Page = this
+  pageSchema.statics.revertDeletedPageRecursively = function(pageData, user, options = {}) {
+    const Page = this
       , path = pageData.path
-      , options = options || { includeDeletedPage: true}
       ;
+    options = Object.assign({ includeDeletedPage: true }, options);
 
     return new Promise(function(resolve, reject) {
       Page
@@ -1161,15 +1154,16 @@ module.exports = function(crowi) {
   /**
    * This is danger.
    */
-  pageSchema.statics.completelyDeletePage = function(pageData, user, options) {
+  pageSchema.statics.completelyDeletePage = function(pageData, user, options = {}) {
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
-    var Bookmark = crowi.model('Bookmark')
+    const Bookmark = crowi.model('Bookmark')
       , Attachment = crowi.model('Attachment')
       , Comment = crowi.model('Comment')
       , Revision = crowi.model('Revision')
       , PageGroupRelation = crowi.model('PageGroupRelation')
       , Page = this
       , pageId = pageData._id
+      , socketClientId = options.socketClientId || null
       ;
 
     debug('Completely delete', pageData.path);
@@ -1190,18 +1184,20 @@ module.exports = function(crowi) {
       }).then(function(done) {
         return PageGroupRelation.removeAllByPage(pageData);
       }).then(function(done) {
-        pageEvent.emit('delete', pageData, user); // update as renamed page
+        if (socketClientId != null) {
+          pageEvent.emit('delete', pageData, user, socketClientId); // update as renamed page
+        }
         resolve(pageData);
       }).catch(reject);
     });
   };
 
-  pageSchema.statics.completelyDeletePageRecursively = function(pageData, user, options) {
+  pageSchema.statics.completelyDeletePageRecursively = function(pageData, user, options = {}) {
     // Delete Bookmarks, Attachments, Revisions, Pages and emit delete
-    var Page = this
+    const Page = this
       , path = pageData.path
-      , options = options || { includeDeletedPage: true }
       ;
+    options = Object.assign({ includeDeletedPage: true }, options);
 
     return new Promise(function(resolve, reject) {
       Page
@@ -1269,32 +1265,31 @@ module.exports = function(crowi) {
       });
   };
 
-  pageSchema.statics.rename = function(pageData, newPagePath, user, options) {
+  pageSchema.statics.rename = async function(pageData, newPagePath, user, options) {
     const Page = this
       , Revision = crowi.model('Revision')
       , path = pageData.path
       , createRedirectPage = options.createRedirectPage || 0
+      , socketClientId = options.socketClientId || null
       ;
 
     // sanitize path
     newPagePath = crowi.xss.process(newPagePath);
 
-    return Page.updatePageProperty(pageData, {updatedAt: Date.now(), path: newPagePath, lastUpdateUser: user})  // pageData の path を変更
-      .then((data) => {
+    await Page.updatePageProperty(pageData, {updatedAt: Date.now(), path: newPagePath, lastUpdateUser: user});
         // reivisions の path を変更
-        return Revision.updateRevisionListByPath(path, {path: newPagePath}, {});
-      })
-      .then(function(data) {
-        pageData.path = newPagePath;
+    await Revision.updateRevisionListByPath(path, {path: newPagePath}, {});
 
         if (createRedirectPage) {
           const body = 'redirect ' + newPagePath;
-          Page.create(path, body, user, {redirectTo: newPagePath});
+      await Page.create(path, body, user, {redirectTo: newPagePath});
         }
-        pageEvent.emit('update', pageData, user); // update as renamed page
 
-        return pageData;
-      });
+    let updatedPageData = await Page.findOne({path: newPagePath});
+    pageEvent.emit('delete', pageData, user, socketClientId);
+    pageEvent.emit('create', updatedPageData, user, socketClientId);
+
+    return updatedPageData;
   };
 
   pageSchema.statics.renameRecursively = function(pageData, newPagePathPrefix, user, options) {
@@ -1329,9 +1324,40 @@ module.exports = function(crowi) {
     }
 
     pageData.pageIdOnHackmd = pageIdOnHackmd;
+
+    return this.syncRevisionToHackmd(pageData);
+  };
+
+  /**
+   * update revisionHackmdSynced
+   * @param {Page} pageData
+   * @param {bool} isSave whether save or not
+   */
+  pageSchema.statics.syncRevisionToHackmd = function(pageData, isSave = true) {
     pageData.revisionHackmdSynced = pageData.revision;
+    pageData.hasDraftOnHackmd = false;
+
+    let returnData = pageData;
+    if (isSave) {
+      returnData = pageData.save();
+    }
+    return returnData;
+  };
+
+  /**
+   * update hasDraftOnHackmd
+   * !! This will be invoked many time from many people !!
+   *
+   * @param {Page} pageData
+   * @param {Boolean} newValue
+   */
+  pageSchema.statics.updateHasDraftOnHackmd = async function(pageData, newValue) {
+    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

+ 28 - 0
lib/routes/admin.js

@@ -1089,6 +1089,34 @@ module.exports = function(crowi, app) {
     return res.json({status: true});
   };
 
+  actions.api.securityPassportTwitterSetting = async(req, res) => {
+    const form = req.form.settingForm;
+
+    if (!req.form.isValid) {
+      return res.json({status: false, message: req.form.errors.join('\n')});
+    }
+
+    debug('form content', form);
+    await saveSettingAsync(form);
+    const config = await crowi.getConfig();
+    
+
+    // reset strategy
+    await crowi.passportService.resetTwitterStrategy();
+    // setup strategy
+    if (Config.isEnabledPassportTwitter(config)) {
+      try {
+        await crowi.passportService.setupTwitterStrategy(true);
+      }
+      catch (err) {
+        // reset
+        await crowi.passportService.resetTwitterStrategy();
+        return res.json({status: false, message: err.message});
+      }
+    }
+
+    return res.json({status: true});
+  };
   actions.api.customizeSetting = function(req, res) {
     const form = req.form.settingForm;
 

+ 117 - 18
lib/routes/hackmd.js

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

+ 13 - 2
lib/routes/index.js

@@ -71,10 +71,13 @@ module.exports = function(crowi, app) {
   // OAuth
   app.post('/_api/admin/security/passport-google' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportGoogle, admin.api.securityPassportGoogleSetting);
   app.post('/_api/admin/security/passport-github' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportGitHub, admin.api.securityPassportGitHubSetting);
+  app.post('/_api/admin/security/passport-twitter' , loginRequired(crowi, app) , middleware.adminRequired() , csrf, form.admin.securityPassportTwitter, admin.api.securityPassportTwitterSetting);
   app.get('/passport/google'                      , loginPassport.loginWithGoogle);
   app.get('/passport/github'                      , loginPassport.loginWithGitHub);
+  app.get('/passport/twitter'                     , loginPassport.loginWithTwitter);
   app.get('/passport/google/callback'             , loginPassport.loginPassportGoogleCallback);
   app.get('/passport/github/callback'             , loginPassport.loginPassportGitHubCallback);
+  app.get('/passport/twitter/callback'             , loginPassport.loginPassportTwitterCallback); 
 
   // markdown admin
   app.get('/admin/markdown'                   , loginRequired(crowi, app) , middleware.adminRequired() , admin.markdown.index);
@@ -202,13 +205,21 @@ module.exports = function(crowi, app) {
   //app.get('/_api/revision/:id'     , user.useUserData()         , revision.api.get);
   //app.get('/_api/r/:revisionId'    , user.useUserData()         , page.api.get);
 
+  /*
+   * WIP: removing pageEdit action
+   * see https://weseek.myjetbrains.com/youtrack/issue/GC-610
+   *
   app.post('/_/edit'                 , form.revision             , loginRequired(crowi, app) , csrf, page.pageEdit);
+  */
+
   app.get('/trash$'                  , loginRequired(crowi, app, false) , page.trashPageShowWrapper);
   app.get('/trash/$'                 , loginRequired(crowi, app, false) , page.trashPageListShowWrapper);
   app.get('/trash/*/$'               , loginRequired(crowi, app, false) , page.deletedPageListShowWrapper);
 
-  app.get('/_hackmd/load-agent'      , hackmd.loadAgent);
-  app.post('/_api/hackmd/integrate'  , accessTokenParser , loginRequired(crowi, app) , csrf, hackmd.integrate);
+  app.get('/_hackmd/load-agent'        , hackmd.loadAgent);
+  app.get('/_hackmd/load-styles'       , hackmd.loadStyles);
+  app.post('/_api/hackmd.integrate'    , accessTokenParser , loginRequired(crowi, app) , csrf, hackmd.validateForApi, hackmd.integrate);
+  app.post('/_api/hackmd.saveOnHackmd' , accessTokenParser , loginRequired(crowi, app) , csrf, hackmd.validateForApi, hackmd.saveOnHackmd);
 
   app.get('/*/$'                   , loginRequired(crowi, app, false) , page.pageListShowWrapper);
   app.get('/*'                     , loginRequired(crowi, app, false) , page.pageShowWrapper);

+ 37 - 0
lib/routes/login-passport.js

@@ -277,6 +277,41 @@ module.exports = function(crowi, app) {
     });
   };
 
+  const loginWithTwitter = function(req, res, next) {
+    if (!passportService.isTwitterStrategySetup) {
+      debug('TwitterStrategy has not been set up');
+      req.flash('warningMessage', 'TwitterStrategy has not been set up');
+      return next();
+    }
+
+    passport.authenticate('twitter')(req, res);
+  };
+
+  const loginPassportTwitterCallback = async(req, res, next) => {
+    const providerId = 'twitter';
+    const strategyName = 'twitter';
+    const response = await promisifiedPassportAuthentication(req, res, next, strategyName);
+    const userInfo = {
+      'id': response.id,
+      'username': response.username,
+      'name': response.displayName
+    };
+
+    const externalAccount = await getOrCreateUser(req, res, next, userInfo, providerId);
+    if (!externalAccount) {
+      return loginFailure(req, res, next);
+    }
+
+    const user = await externalAccount.getPopulatedUser();
+
+    // login
+    req.logIn(user, err => {
+      if (err) { return next(err) }
+      return loginSuccess(req, res, user);
+    });
+  };
+
+
   const promisifiedPassportAuthentication = (req, res, next, strategyName) => {
     return new Promise((resolve, reject) => {
       passport.authenticate(strategyName, (err, response, info) => {
@@ -336,7 +371,9 @@ module.exports = function(crowi, app) {
     loginWithLocal,
     loginWithGoogle,
     loginWithGitHub,
+    loginWithTwitter,
     loginPassportGoogleCallback,
     loginPassportGitHubCallback,
+    loginPassportTwitterCallback,
   };
 };

+ 208 - 226
lib/routes/page.js

@@ -22,19 +22,36 @@ module.exports = function(crowi, app) {
 
   // register page events
 
-  var pageEvent = crowi.event('page');
-  pageEvent.on('update', function(page, user) {
-    crowi.getIo().sockets.emit('page edited', {page, user});
+  const pageEvent = crowi.event('page');
+  pageEvent.on('create', function(page, user, socketClientId) {
+    page = serializeToObj(page);
+    crowi.getIo().sockets.emit('page:create', {page, user, socketClientId});
+  });
+  pageEvent.on('update', function(page, user, socketClientId) {
+    page = serializeToObj(page);
+    crowi.getIo().sockets.emit('page:update', {page, user, socketClientId});
+  });
+  pageEvent.on('delete', function(page, user, socketClientId) {
+    page = serializeToObj(page);
+    crowi.getIo().sockets.emit('page:delete', {page, user, socketClientId});
   });
 
 
+  function serializeToObj(page) {
+    const returnObj = page.toObject();
+    if (page.revisionHackmdSynced != null && page.revisionHackmdSynced._id != null) {
+      returnObj.revisionHackmdSynced = page.revisionHackmdSynced._id;
+    }
+    return returnObj;
+  }
+
   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 +60,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 +88,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 +183,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 +217,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 +297,7 @@ module.exports = function(crowi, app) {
       pageRelatedGroup: null,
       template: null,
       revisionHackmdSynced: null,
+      hasDraftOnHackmd: false,
       slack: '',
     };
 
@@ -282,8 +321,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 +455,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 +495,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 +506,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 +623,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 +698,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 +732,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 +748,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 +769,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 +786,50 @@ 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;
+    const socketClientId = req.body.socketClientId || undefined;
 
     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() };
+        const options = {grant, grantUserGroupId, socketClientId};
+        return Page.create(pagePath, body, req.user, options);
+      })
+      .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: serializeToObj(createdPage) };
+    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 +846,62 @@ 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;
+    const isSyncRevisionToHackmd = !!req.body.isSyncRevisionToHackmd;     // cast to boolean
+    const socketClientId = req.body.socketClientId || undefined;
 
     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('Posted param "revisionId" is outdated.');
+        }
 
-      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 options = {isSyncRevisionToHackmd, socketClientId};
+        if (grant != null) {
+          options.grant = grant;
+        }
+        if (grantUserGroupId != null) {
+          options.grantUserGroupId = grantUserGroupId;
+        }
+
+        // store previous revision
+        previousRevision = pageData.revision;
+
+        return Page.updatePage(pageData, pageBody, req.user, options);
+      })
+      .catch(function(err) {
+        logger.error('error on _api/pages.update', err);
+        res.json(ApiResponse.error(err));
+      });
+
+    const result = { page: serializeToObj(updatedPage) };
+    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,8 +931,8 @@ module.exports = function(crowi, app) {
     }
 
     pageFinder.then(function(pageData) {
-      var result = {};
-      result.page = pageData;
+      const result = {};
+      result.page = pageData;   // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
 
       return res.json(ApiResponse.success(result));
     }).catch(function(err) {
@@ -969,7 +948,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 +957,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 +975,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 +1004,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 +1026,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 +1039,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,24 +1056,27 @@ 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;
+    const socketClientId = req.body.socketClientId || undefined;
 
     // get completely flag
     const isCompletely = (req.body.completely != null);
     // get recursively flag
     const isRecursively = (req.body.recursively != null);
 
+    const options = {socketClientId};
+
     Page.findPageByIdAndGrantedUser(pageId, req.user)
       .then(function(pageData) {
         debug('Delete page', pageData._id, pageData.path);
 
         if (isCompletely) {
           if (isRecursively) {
-            return Page.completelyDeletePageRecursively(pageData, req.user);
+            return Page.completelyDeletePageRecursively(pageData, req.user, options);
           }
           else {
-            return Page.completelyDeletePage(pageData, req.user);
+            return Page.completelyDeletePage(pageData, req.user, options);
           }
         }
 
@@ -1105,16 +1087,16 @@ module.exports = function(crowi, app) {
         }
 
         if (isRecursively) {
-          return Page.deletePageRecursively(pageData, req.user);
+          return Page.deletePageRecursively(pageData, req.user, options);
         }
         else {
-          return Page.deletePage(pageData, req.user);
+          return Page.deletePage(pageData, req.user, options);
         }
       })
       .then(function(data) {
         debug('Page deleted', data.path);
-        var result = {};
-        result.page = data;
+        const result = {};
+        result.page = data;   // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
 
         res.json(ApiResponse.success(result));
         return data;
@@ -1124,7 +1106,7 @@ module.exports = function(crowi, app) {
         return globalNotificationService.notifyPageDelete(page);
       })
       .catch(function(err) {
-        debug('Error occured while get setting', err, err.stack);
+        logger.error('Error occured while get setting', err, err.stack);
         return res.json(ApiResponse.error('Failed to delete page.'));
       });
   };
@@ -1136,8 +1118,9 @@ module.exports = function(crowi, app) {
    *
    * @apiParam {String} page_id Page Id.
    */
-  api.revertRemove = function(req, res) {
-    var pageId = req.body.page_id;
+  api.revertRemove = function(req, res, options) {
+    const pageId = req.body.page_id;
+    const socketClientId = req.body.socketClientId || undefined;
 
     // get recursively flag
     const isRecursively = (req.body.recursively !== undefined);
@@ -1146,19 +1129,18 @@ module.exports = function(crowi, app) {
     .then(function(pageData) {
 
       if (isRecursively) {
-        return Page.revertDeletedPageRecursively(pageData, req.user);
+        return Page.revertDeletedPageRecursively(pageData, req.user, {socketClientId});
       }
       else {
-        return Page.revertDeletedPage(pageData, req.user);
+        return Page.revertDeletedPage(pageData, req.user, {socketClientId});
       }
     }).then(function(data) {
-      debug('Complete to revert deleted page', data.path);
-      var result = {};
-      result.page = data;
+      const result = {};
+      result.page = data;   // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
 
       return res.json(ApiResponse.success(result));
     }).catch(function(err) {
-      debug('Error occured while get setting', err, err.stack);
+      logger.error('Error occured while get setting', err, err.stack);
       return res.json(ApiResponse.error('Failed to revert deleted page.'));
     });
   };
@@ -1175,15 +1157,15 @@ 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,
+      socketClientId: +req.body.socketClientId || undefined,
     };
-    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,8 +1194,8 @@ module.exports = function(crowi, app) {
 
       })
       .then(function() {
-        var result = {};
-        result.page = page;
+        const result = {};
+        result.page = page;   // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
 
         return res.json(ApiResponse.success(result));
       })
@@ -1237,8 +1219,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 +1241,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,8 +1251,8 @@ module.exports = function(crowi, app) {
         .then(() => pageData);
     }).then(function(data) {
       debug('Redirect Page deleted', data.path);
-      var result = {};
-      result.page = data;
+      const result = {};
+      result.page = data;   // TODO consider to use serializeToObj method -- 2018.08.06 Yuki Takei
 
       return res.json(ApiResponse.success(result));
     }).catch(function(err) {

+ 47 - 1
lib/service/passport.js

@@ -4,6 +4,7 @@ const LocalStrategy = require('passport-local').Strategy;
 const LdapStrategy = require('passport-ldapauth');
 const GoogleStrategy = require('passport-google-auth').Strategy;
 const GitHubStrategy = require('passport-github').Strategy;
+const TwitterStrategy = require('passport-twitter').Strategy;
 
 /**
  * the service class of Passport
@@ -342,7 +343,6 @@ class PassportService {
     this.isGitHubStrategySetup = true;
     debug('GitHubStrategy: setup is done');
   }
-
   /**
    * reset GoogleStrategy
    *
@@ -354,6 +354,52 @@ class PassportService {
     this.isGitHubStrategySetup = false;
   }
 
+  setupTwitterStrategy() {
+    // check whether the strategy has already been set up
+    if (this.isTwitterStrategySetup) {
+      throw new Error('TwitterStrategy has already been set up');
+    }
+
+    const config = this.crowi.config;
+    const Config = this.crowi.model('Config');
+    //this
+    const isTwitterEnabled = Config.isEnabledPassportTwitter(config);
+
+    // when disabled
+    if (!isTwitterEnabled) {
+      return;
+    }
+
+    debug('TwitterStrategy: setting up..');
+    passport.use(new TwitterStrategy({
+      consumerKey: config.crowi['security:passport-twitter:consumerKey'] || process.env.OAUTH_TWITTER_CONSUMER_KEY,
+      consumerSecret: config.crowi['security:passport-twitter:consumerSecret'] || process.env.OAUTH_TWITTER_CONSUMER_SECRET,
+      callbackURL: config.crowi['security:passport-twitter:callbackUrl'] || process.env.OAUTH_TWITTER_CALLBACK_URI,
+      skipUserProfile: false,
+    }, function(accessToken, refreshToken, profile, done) {
+      if (profile) {
+        return done(null, profile);
+      }
+      else {
+        return done(null, false);
+      }
+    }));
+
+    this.isTwitterStrategySetup = true;
+    debug('TwitterStrategy: setup is done');
+  }
+
+  /**
+   * reset GoogleStrategy
+   *
+   * @memberof PassportService
+   */
+  resetTwitterStrategy() {
+    debug('TwitterStrategy: reset');
+    passport.unuse('twitter');
+    this.isTwitterStrategySetup = false;
+  }
+
   /**
    * setup serializer and deserializer
    *

+ 9 - 0
lib/util/swigFunctions.js

@@ -102,6 +102,11 @@ module.exports = function(crowi, app, req, locals) {
     return locals.isEnabledPassport() && config.crowi['security:passport-github:isEnabled'];
   };
 
+  locals.passportTwitterLoginEnabled = function() {
+    var config = crowi.getConfig();
+    return locals.isEnabledPassport() && config.crowi['security:passport-twitter:isEnabled'];
+  };
+
   locals.searchConfigured = function() {
     if (crowi.getSearcher()) {
       return true;
@@ -109,6 +114,10 @@ module.exports = function(crowi, app, req, locals) {
     return false;
   };
 
+  locals.isHackmdSetup = function() {
+    return process.env.HACKMD_URI != null;
+  };
+
   locals.isEnabledPlugins = function() {
     let config = crowi.getConfig();
     return Config.isEnabledPlugins(config);

+ 11 - 24
lib/views/_form.html

@@ -1,8 +1,3 @@
-{% block html_head_loading_legacy %}
-  <script src="{{ webpack_asset('js/legacy-form.js') }}" defer></script>
-  {% parent %}
-{% endblock %}
-
 {% if req.form.errors %}
 <div class="alert alert-danger">
   <ul>
@@ -14,26 +9,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>

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

@@ -252,10 +252,10 @@
               <a href="#passport-github" data-toggle="tab" role="tab"><i class="fa fa-github"></i> GitHub</a>
             </li>
             <li class="tbd">
-              <a href="#passport-facebook" data-toggle="tab" role="tab"><i class="fa fa-facebook"></i> (TBD) Facebook</a>
+              <a href="#passport-twitter" data-toggle="tab" role="tab"><i class="fa fa-twitter"></i> Twitter</a>
             </li>
             <li class="tbd">
-              <a href="#passport-twitter" data-toggle="tab" role="tab"><i class="fa fa-twitter"></i> (TBD) Twitter</a>
+              <a href="#passport-facebook" data-toggle="tab" role="tab"><i class="fa fa-facebook"></i> (TBD) Facebook</a>
             </li>
           </ul>
 
@@ -288,7 +288,7 @@
   </div>
 
   <script>
-    $('#generalSetting, #googleSetting, #mechanismSetting, #githubSetting').each(function() {
+    $('#generalSetting, #googleSetting, #mechanismSetting, #githubSetting, #twitterSetting').each(function() {
       $(this).submit(function()
       {
         function showMessage(formId, msg, status) {

+ 1 - 1
lib/views/admin/widget/passport/github.html

@@ -92,7 +92,7 @@
 <hr>
 <h4>
   <i class="fa fa-question-circle" aria-hidden="true"></i>
-  <a href="#collapseHelpForGithubOauth" data-toggle="collapse">How to configure GitHub OAuth?</a>
+  <a href="#collapseHelpForGithubOauth" data-toggle="collapse">{{ t("security_setting.OAuth.how_to.github") }}</a>
 </h4>
 <ol id="collapseHelpForGithubOauth" class="collapse">
   <li>{{ t("security_setting.OAuth.GitHub.register_1", "https://github.com/settings/developers", "GitHub Developer Settings") }}</li>

+ 1 - 1
lib/views/admin/widget/passport/google-oauth.html

@@ -92,7 +92,7 @@
 <hr>
 <h4>
   <i class="fa fa-question-circle" aria-hidden="true"></i>
-  <a href="#collapseHelpForGoogleOauth" data-toggle="collapse">How to configure Google OAuth?</a>
+  <a href="#collapseHelpForGoogleOauth" data-toggle="collapse">{{ t("security_setting.OAuth.how_to.google") }}</a>
 </h4>
 <ol id="collapseHelpForGoogleOauth" class="collapse">
   <li>{{ t("security_setting.OAuth.Google.register_1", "https://console.cloud.google.com/apis/credentials", "Google Cloud Platform API Manager") }}</li>

+ 115 - 4
lib/views/admin/widget/passport/twitter.html

@@ -1,6 +1,117 @@
-<form action="" method="post" class="form-horizontal passportStrategy" id="twitterOauthSetting" role="form">
-  <fieldset>
-    <legend>Twitter OAuth {{ t("security_setting.configuration") }}</legend>
-    <p class="well">(TBD)</p>
+<form action="/_api/admin/security/passport-twitter" method="post" class="form-horizontal passportStrategy" id="twitterSetting" role="form"
+    {% if isRestartingServerNeeded %}style="opacity: 0.4;"{% endif %}>
+  <legend class="alert-anchor">{{ t("security_setting.OAuth.Twitter.name") }}{{ t("security_setting.configuration") }}</legend>
+
+  {% set nameForIsTwitterEnabled = "settingForm[security:passport-twitter:isEnabled]" %}
+  {% set isTwitterEnabled = settingForm['security:passport-twitter:isEnabled'] %}
+  
+  <div class="form-group">
+    <label for="{{nameForIsTwitterEnabled}}" class="col-xs-3 control-label">{{ t("security_setting.OAuth.Twitter.name") }}</label>
+    <div class="col-xs-6">
+      <div class="btn-group btn-toggle" data-toggle="buttons">
+        <label class="btn btn-default btn-rounded btn-outline {% if isTwitterEnabled %}active{% endif %}" data-active-class="primary">
+          <input name="{{nameForIsTwitterEnabled}}" value="true" type="radio"
+              {% if true === isTwitterEnabled %}checked{% endif %}> ON
+        </label>
+        <label class="btn btn-default btn-rounded btn-outline {% if !isTwitterEnabled %}active{% endif %}" data-active-class="default">
+          <input name="{{nameForIsTwitterEnabled}}" value="false" type="radio"
+              {% if !isTwitterEnabled %}checked{% endif %}> OFF
+        </label>
+      </div>
+    </div>
+  </div>
+  <fieldset id="passport-twitter-hide-when-disabled" {%if !isTwitterEnabled %}style="display: none;"{% endif %}>
+
+
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-twitter:consumerKey]" class="col-xs-3 control-label">{{ t("security_setting.clientID") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-twitter:consumerKey]" value="{{ settingForm['security:passport-twitter:consumerKey'] || '' }}">
+        <p class="help-block">
+          <small>
+                {{ t("security_setting.Use env var if empty", "OAUTH_TWITTER_CONSUMER_KEY") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-twitter:consumerSecret]" class="col-xs-3 control-label">{{ t("security_setting.client_secret") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-twitter:consumerSecret]" value="{{ settingForm['security:passport-twitter:consumerSecret'] || '' }}">
+        <p class="help-block">
+          <small>
+             {{ t("security_setting.Use env var if empty", "OAUTH_TWITTER_CONSUMER_SECRET") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    <div class="form-group">
+      <label for="settingForm[security:passport-twitter:callbackUrl]" class="col-xs-3 control-label">{{ t("security_setting.callback_URL") }}</label>
+      <div class="col-xs-6">
+        <input class="form-control" type="text" name="settingForm[security:passport-twitter:callbackUrl]" value="{{ settingForm['security:passport-twitter:callbackUrl'] || '' }}">
+        <p class="help-block">
+          <small>
+            {{ t("security_setting.Use env var if empty", "OAUTH_TWITTER_CALLBACK_URL") }}
+          </small>
+        </p>
+      </div>
+    </div>
+
+    
+    <div class="form-group">
+      <div class="col-xs-6 col-xs-offset-3">
+        <div class="checkbox checkbox-info">
+          <input type="checkbox" id="bindByUserName-Twitter" name="settingForm[security:passport-twitter:isSameUsernameTreatedAsIdenticalUser]" value="1"
+              {% if settingForm['security:passport-twitter:isSameUsernameTreatedAsIdenticalUser'] %}checked{% endif %} />
+          <label for="bindByUserName-Twitter">
+            {{ t("security_setting.Treat username matching as identical", "username") }}
+          </label>
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.Treat username matching as identical_warn", "username") }}
+            </small>
+          </p>
+        </div>
+      </div>
+    </div>
+
   </fieldset>
+
+  <div class="form-group" id="btn-update">
+    <div class="col-xs-offset-3 col-xs-6">
+      <input type="hidden" name="_csrf" value="{{ csrf() }}">
+      <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
+    </div>
+  </div>
+
 </form>
+
+{# Help Section #}
+<hr>
+<h4>
+  <i class="fa fa-question-circle" aria-hidden="true"></i>
+  <a href="#collapseHelpForTwitterOauth" data-toggle="collapse">{{ t("security_setting.OAuth.how_to.twitter") }}</a>
+</h4>
+<ol id="collapseHelpForTwitterOauth" class="collapse">
+  <li>{{ t("security_setting.OAuth.Twitter.register_1", "https://apps.twitter.com/", "Twitter Application Management") }}</li>
+  <li>{{ t("security_setting.OAuth.Twitter.register_2") }}</li>
+  <li>{{ t("security_setting.OAuth.Twitter.register_3") }}</li>
+  <li>{{ t("security_setting.OAuth.Twitter.register_4", "https://${growi.host}/passport/twitter/callback", "${growi.host}") }}</li>
+</ol>
+
+<script>
+  $('input[name="settingForm[security:passport-twitter:isEnabled]"]').change(function() {
+      const isEnabled = ($(this).val() === "true");
+
+      if (isEnabled) {
+        $('#passport-twitter-hide-when-disabled').show(400);
+      }
+      else {
+        $('#passport-twitter-hide-when-disabled').hide(400);
+      }
+    });
+</script>
+

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

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

@@ -39,10 +39,12 @@
     </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' %}
 
   </div>
+
+  <div id="page-status-alert"></div>
 </div>

+ 4 - 7
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 %}
 
@@ -52,7 +51,5 @@
 
   </div>
 
-  <div id="notifPageEdited" class="myadmin-alert alert-warning myadmin-alert-bottom alertbottom2">
-    <i class="fa fa-exclamation-triangle"></i> <span class="edited-user"></span> {{ t('edited this page') }} <a href="javascript:location.reload();"><i class="fa fa-angle-double-right"></i> {{ t('Load latest') }}</a>
-  </div>
+  <div id="page-status-alert"></div>
 </div>

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

+ 2 - 2
lib/views/widget/user_page_header.html

@@ -3,9 +3,9 @@
 
     <h4 id="revision-path"></h4>
 
-    <div class="users-meta d-flex align-items-center">
+    <div class="users-info d-flex align-items-center">
       <img src="{{ pageUser|picture }}" class="picture img-circle">
-      <div class="m-l-30" style="flex: 1;">
+      <div class="users-meta" style="flex: 1;">
         <div class="d-flex align-items-center">
           <h1>
             {{ pageUser.name }}

+ 3 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.1.14-RC",
+  "version": "3.2.0-RC6",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -102,6 +102,7 @@
     "passport-google-auth": "^1.0.2",
     "passport-ldapauth": "^2.0.0",
     "passport-local": "^1.0.0",
+    "passport-twitter": "^1.0.4",
     "rimraf": "^2.6.1",
     "slack-node": "^0.1.8",
     "socket.io": "^2.0.3",
@@ -167,6 +168,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",

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

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

+ 294 - 81
resource/js/app.js

@@ -1,9 +1,13 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
 import { I18nextProvider } from 'react-i18next';
+import * as toastr from 'toastr';
+
+import io from 'socket.io-client';
 
 import i18nFactory from './i18n';
 
+import loggerFactory from '@alias/logger';
 import Xss from '../../lib/util/xss';
 
 import Crowi from './util/Crowi';
@@ -15,15 +19,15 @@ 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 PageStatusAlert  from './components/PageStatusAlert';
 import SeenUserList     from './components/SeenUserList';
 import RevisionPath     from './components/Page/RevisionPath';
 import RevisionUrl      from './components/Page/RevisionUrl';
@@ -36,6 +40,8 @@ import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
 
 import * as entities from 'entities';
 
+const logger = loggerFactory('growi:app');
+
 if (!window) {
   window = {};
 }
@@ -43,6 +49,8 @@ if (!window) {
 const userlang = $('body').data('userlang');
 const i18n = i18nFactory(userlang);
 
+const socket = io();
+
 // setup xss library
 const xss = new Xss();
 window.xss = xss;
@@ -52,6 +60,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 +72,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');
@@ -84,6 +94,7 @@ crowi.setConfig(JSON.parse(document.getElementById('crowi-context-hydrate').text
 if (isLoggedin) {
   crowi.fetchUsers();
 }
+const socketClientId = crowi.getSocketClientId();
 
 const crowiRenderer = new GrowiRenderer(crowi, null, {
   mode: 'page',
@@ -147,6 +158,191 @@ Object.keys(componentMappings).forEach((key) => {
   }
 });
 
+
+/**
+ * save success handler when reloading is not needed
+ * @param {object} page Page instance
+ */
+const saveWithShortcutSuccessHandler = function(page) {
+  const editorMode = crowi.getCrowiForJquery().getCurrentEditorMode();
+
+  // show toastr
+  toastr.success(undefined, 'Saved successfully', {
+    closeButton: true,
+    progressBar: true,
+    newestOnTop: false,
+    showDuration: '100',
+    hideDuration: '100',
+    timeOut: '1200',
+    extendedTimeOut: '150',
+  });
+
+  pageId = page._id;
+  pageRevisionId = page.revision._id;
+  pageRevisionIdHackmdSynced = page.revisionHackmdSynced;
+
+  // set page id to SavePageControls
+  componentInstances.savePageControls.setPageId(pageId);
+
+  // Page component
+  if (componentInstances.page != null) {
+    componentInstances.page.setMarkdown(page.revision.body);
+  }
+  // PageEditor component
+  if (componentInstances.pageEditor != null) {
+    const updateEditorValue = (editorMode !== 'builtin');
+    componentInstances.pageEditor.setMarkdown(page.revision.body, updateEditorValue);
+  }
+  // PageEditorByHackmd component
+  if (componentInstances.pageEditorByHackmd != null) {
+    // clear state of PageEditorByHackmd
+    componentInstances.pageEditorByHackmd.clearRevisionStatus(pageRevisionId, pageRevisionIdHackmdSynced);
+    // reset
+    if (editorMode !== 'hackmd') {
+      componentInstances.pageEditorByHackmd.setMarkdown(page.revision.body, false);
+      componentInstances.pageEditorByHackmd.reset();
+    }
+  }
+  // PageStatusAlert component
+  const pageStatusAlert = componentInstances.pageStatusAlert;
+  // clear state of PageStatusAlert
+  if (componentInstances.pageStatusAlert != null) {
+    pageStatusAlert.clearRevisionStatus(pageRevisionId, pageRevisionIdHackmdSynced);
+  }
+};
+
+const errorHandler = function(error) {
+  toastr.error(error.message, 'Error occured', {
+    closeButton: true,
+    progressBar: true,
+    newestOnTop: false,
+    showDuration: '100',
+    hideDuration: '100',
+    timeOut: '3000',
+  });
+};
+
+const saveWithShortcut = function(markdown) {
+  const editorMode = crowi.getCrowiForJquery().getCurrentEditorMode();
+  if (editorMode == null) {
+    // do nothing
+    return;
+  }
+
+  let revisionId = pageRevisionId;
+  // get options
+  const options = componentInstances.savePageControls.getCurrentOptionsToSave();
+  options.socketClientId = socketClientId;
+
+  if (editorMode === 'hackmd') {
+    // set option to sync
+    options.isSyncRevisionToHackmd = true;
+    // use revisionId of PageEditorByHackmd
+    revisionId = componentInstances.pageEditorByHackmd.getRevisionIdHackmdSynced();
+  }
+
+  let promise = undefined;
+  if (pageId == null) {
+    promise = crowi.createPage(pagePath, markdown, options);
+  }
+  else {
+    promise = crowi.updatePage(pageId, revisionId, 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;
+  }
+
+  let revisionId = pageRevisionId;
+  // get options
+  const options = componentInstances.savePageControls.getCurrentOptionsToSave();
+  options.socketClientId = socketClientId;
+
+  let promise = undefined;
+  if (editorMode === 'builtin') {
+    // get markdown
+    promise = Promise.resolve(componentInstances.pageEditor.getMarkdown());
+  }
+  else {
+    // get markdown
+    promise = componentInstances.pageEditorByHackmd.getMarkdown();
+    // use revisionId of PageEditorByHackmd
+    revisionId = componentInstances.pageEditorByHackmd.getRevisionIdHackmdSynced();
+    // set option to sync
+    options.isSyncRevisionToHackmd = true;
+  }
+  // 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, revisionId, 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 +352,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);
 }
 
@@ -191,28 +378,14 @@ if (writeCommentElem) {
     <CommentForm crowi={crowi}
       crowiOriginRenderer={crowiRenderer}
       pageId={pageId}
-      revisionId={pageRevisionId}
       pagePath={pagePath}
+      revisionId={pageRevisionId}
       onPostComplete={postCompleteHandler}
       editorOptions={editorOptions}
       slackChannels = {slackChannels}/>,
     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,62 +403,24 @@ 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;
+
+// render PageStatusAlert
+let pageStatusAlert = null;
+const pageStatusAlertElem = document.getElementById('page-status-alert');
+if (pageStatusAlertElem) {
   ReactDOM.render(
     <I18nextProvider i18n={i18n}>
-      <GrantSelector crowi={crowi}
-        pageGrant={pageGrant} pageGrantGroupId={pageGrantGroupId} pageGrantGroupName={pageGrantGroupName}
-        onChangePageGrant={updateGrantElem}
-        onDeterminePageGrantGroupId={updateGrantGroupElem}
-        onDeterminePageGrantGroupName={updateGrantGroupNameElem} />
+      <PageStatusAlert crowi={crowi}
+          ref={(elem) => {
+            if (pageStatusAlert == null) {
+              pageStatusAlert = elem.getWrappedInstance();
+            }
+          }}
+          revisionId={pageRevisionId} revisionIdHackmdSynced={pageRevisionIdHackmdSynced} hasDraftOnHackmd={hasDraftOnHackmd} />
     </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
+    pageStatusAlertElem
   );
+  componentInstances.pageStatusAlert = pageStatusAlert;
 }
 
 // render for admin
@@ -320,6 +455,84 @@ if (customHeaderEditorElem != null) {
   );
 }
 
+// notification from websocket
+function updatePageStatusAlert(page, user) {
+  const pageStatusAlert = componentInstances.pageStatusAlert;
+  if (pageStatusAlert != null) {
+    const revisionId = page.revision._id;
+    const revisionIdHackmdSynced = page.revisionHackmdSynced;
+    pageStatusAlert.setRevisionId(revisionId, revisionIdHackmdSynced);
+    pageStatusAlert.setLastUpdateUsername(user.name);
+  }
+}
+socket.on('page:create', function(data) {
+  // skip if triggered myself
+  if (data.socketClientId != null && data.socketClientId === socketClientId) {
+    return;
+  }
+
+  logger.debug({ obj: data }, `websocket on 'page:create'`); // eslint-disable-line quotes
+
+  // update PageStatusAlert
+  if (data.page.path == pagePath) {
+    updatePageStatusAlert(data.page, data.user);
+  }
+});
+socket.on('page:update', function(data) {
+  // skip if triggered myself
+  if (data.socketClientId != null && data.socketClientId === socketClientId) {
+    return;
+  }
+
+  logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
+
+  if (data.page.path == pagePath) {
+    // update PageStatusAlert
+    updatePageStatusAlert(data.page, data.user);
+    // update PageEditorByHackmd
+    const pageEditorByHackmd = componentInstances.pageEditorByHackmd;
+    if (pageEditorByHackmd != null) {
+      const page = data.page;
+      pageEditorByHackmd.setRevisionId(page.revision._id, page.revisionHackmdSynced);
+      pageEditorByHackmd.setHasDraftOnHackmd(data.page.hasDraftOnHackmd);
+    }
+  }
+});
+socket.on('page:delete', function(data) {
+  // skip if triggered myself
+  if (data.socketClientId != null && data.socketClientId === socketClientId) {
+    return;
+  }
+
+  logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes
+
+  // update PageStatusAlert
+  if (data.page.path == pagePath) {
+    updatePageStatusAlert(data.page, data.user);
+  }
+});
+socket.on('page:editingWithHackmd', function(data) {
+  // skip if triggered myself
+  if (data.socketClientId != null && data.socketClientId === socketClientId) {
+    return;
+  }
+
+  logger.debug({ obj: data }, `websocket on 'page:editingWithHackmd'`); // eslint-disable-line quotes
+
+  if (data.page.path == pagePath) {
+    // update PageStatusAlert
+    const pageStatusAlert = componentInstances.pageStatusAlert;
+    if (pageStatusAlert != null) {
+      pageStatusAlert.setHasDraftOnHackmd(data.page.hasDraftOnHackmd);
+    }
+    // update PageEditorByHackmd
+    const pageEditorByHackmd = componentInstances.pageEditorByHackmd;
+    if (pageEditorByHackmd != null) {
+      pageEditorByHackmd.setHasDraftOnHackmd(data.page.hasDraftOnHackmd);
+    }
+  }
+});
+
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', function() {
   ReactDOM.render(<PageHistory pageId={pageId} crowi={crowi} />, document.getElementById('revision-history'));

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

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

+ 10 - 8
resource/js/components/PageComment/CommentForm.js

@@ -54,8 +54,8 @@ export default class CommentForm extends React.Component {
     this.handleSelect = this.handleSelect.bind(this);
     this.apiErrorHandler = this.apiErrorHandler.bind(this);
     this.onUpload = this.onUpload.bind(this);
-    this.onChannelChange = this.onChannelChange.bind(this);
-    this.onSlackOnChange = this.onSlackOnChange.bind(this);
+    this.onSlackEnabledFlagChange = this.onSlackEnabledFlagChange.bind(this);
+    this.onSlackChannelsChange = this.onSlackChannelsChange.bind(this);
   }
 
   updateState(value) {
@@ -74,11 +74,11 @@ export default class CommentForm extends React.Component {
     this.renderHtml(this.state.comment);
   }
 
-  onSlackOnChange(value) {
+  onSlackEnabledFlagChange(value) {
     this.setState({isSlackEnabled: value});
   }
 
-  onChannelChange(value) {
+  onSlackChannelsChange(value) {
     this.setState({slackChannels: value});
   }
 
@@ -269,11 +269,13 @@ export default class CommentForm extends React.Component {
                 </div>
                 <div className="comment-submit">
                   <div className="d-flex">
+                    <label style={{flex: 1}}>
                     { this.state.key == 1 &&
-                      <label style={{flex: 1}}>
+                      <span>
                         <input type="checkbox" id="comment-form-is-markdown" name="isMarkdown" checked={this.state.isMarkdown} value="1" onChange={this.updateStateCheckbox} /> Markdown
-                      </label>
+                      </span>
                     }
+                    </label>
                     <span className="hidden-xs">{ this.state.errorMessage && errorMessage }</span>
                     { this.state.hasSlackConfig &&
                       <div className="form-inline align-self-center mr-md-2">
@@ -281,10 +283,10 @@ export default class CommentForm extends React.Component {
                           crowi={this.props.crowi}
                           pageId={this.props.pageId}
                           pagePath={this.props.pagePath}
-                          onSlackOnChange={this.onSlackOnChange}
-                          onChannelChange={this.onChannelChange}
                           isSlackEnabled={this.state.isSlackEnabled}
                           slackChannels={this.state.slackChannels}
+                          onEnabledFlagChange={this.onSlackEnabledFlagChange}
+                          onChannelChange={this.onSlackChannelsChange}
                         />
                       </div>
                     }

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

@@ -1,7 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import * as toastr from 'toastr';
 import { throttle, debounce } from 'throttle-debounce';
 
 import GrowiRenderer from '../util/GrowiRenderer';
@@ -37,15 +36,12 @@ export default class PageEditor extends React.Component {
     this.setCaretLine = this.setCaretLine.bind(this);
     this.focusToEditor = this.focusToEditor.bind(this);
     this.onMarkdownChanged = this.onMarkdownChanged.bind(this);
-    this.onSave = this.onSave.bind(this);
     this.onUpload = this.onUpload.bind(this);
     this.onEditorScroll = this.onEditorScroll.bind(this);
     this.onEditorScrollCursorIntoView = this.onEditorScrollCursorIntoView.bind(this);
     this.onPreviewScroll = this.onPreviewScroll.bind(this);
     this.saveDraft = this.saveDraft.bind(this);
     this.clearDraft = this.clearDraft.bind(this);
-    this.pageSavedHandler = this.pageSavedHandler.bind(this);
-    this.apiErrorHandler = this.apiErrorHandler.bind(this);
 
     // for scrolling
     this.lastScrolledDateWithCursor = null;
@@ -56,7 +52,7 @@ export default class PageEditor extends React.Component {
     this.scrollPreviewByEditorLineWithThrottle = throttle(20, this.scrollPreviewByEditorLine);
     this.scrollPreviewByCursorMovingWithThrottle = throttle(20, this.scrollPreviewByCursorMoving);
     this.scrollEditorByPreviewScrollWithThrottle = throttle(20, this.scrollEditorByPreviewScroll);
-    this.renderWithDebounce = debounce(50, throttle(100, this.renderPreview));
+    this.renderPreviewWithDebounce = debounce(50, throttle(100, this.renderPreview));
     this.saveDraftWithDebounce = debounce(800, this.saveDraft);
   }
 
@@ -65,6 +61,17 @@ export default class PageEditor extends React.Component {
     this.renderPreview(this.state.markdown);
   }
 
+  getMarkdown() {
+    return this.state.markdown;
+  }
+
+  setMarkdown(markdown, updateEditorValue = true) {
+    this.setState({ markdown });
+    if (updateEditorValue) {
+      this.refs.editor.setValue(markdown);
+    }
+  }
+
   focusToEditor() {
     this.refs.editor.forceToFocus();
   }
@@ -99,53 +106,10 @@ export default class PageEditor extends React.Component {
    * @param {string} value
    */
   onMarkdownChanged(value) {
-    this.renderWithDebounce(value);
+    this.renderPreviewWithDebounce(value);
     this.saveDraftWithDebounce();
   }
 
-  /**
-   * the save event handler
-   */
-  onSave() {
-    let endpoint;
-    let data;
-
-    // update
-    if (this.state.pageId != null) {
-      endpoint = '/pages.update';
-      data = {
-        page_id: this.state.pageId,
-        revision_id: this.state.revisionId,
-        body: this.state.markdown,
-      };
-    }
-    // create
-    else {
-      endpoint = '/pages.create';
-      data = {
-        path: this.props.pagePath,
-        body: this.state.markdown,
-      };
-    }
-
-    this.props.crowi.apiPost(endpoint, data)
-      .then((res) => {
-        // show toastr
-        toastr.success(undefined, 'Saved successfully', {
-          closeButton: true,
-          progressBar: true,
-          newestOnTop: false,
-          showDuration: '100',
-          hideDuration: '100',
-          timeOut: '1200',
-          extendedTimeOut: '150',
-        });
-
-        this.pageSavedHandler(res.page);
-      })
-      .catch(this.apiErrorHandler);
-  }
-
   /**
    * the upload event handler
    * @param {any} files
@@ -293,34 +257,6 @@ export default class PageEditor extends React.Component {
     this.props.crowi.clearDraft(this.props.pagePath);
   }
 
-  pageSavedHandler(page) {
-    // update states
-    this.setState({
-      pageId: page.id,
-      revisionId: page.revision._id,
-      markdown: page.revision.body
-    });
-
-    // clear draft
-    this.clearDraft();
-
-    // dispatch onSaveSuccess event
-    if (this.props.onSaveSuccess != null) {
-      this.props.onSaveSuccess(page);
-    }
-  }
-
-  apiErrorHandler(error) {
-    toastr.error(error.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
-  }
-
   renderPreview(value) {
     this.setState({ markdown: value });
 
@@ -351,8 +287,6 @@ export default class PageEditor extends React.Component {
       .then(() => interceptorManager.process('preRenderPreviewHtml', context))
       .then(() => {
         this.setState({ html: context.parsedHTML });
-        // set html to the hidden input (for submitting to save)
-        $('#form-body').val(this.state.markdown);
       })
       // process interceptors for post rendering
       .then(() => interceptorManager.process('postRenderPreviewHtml', context));
@@ -374,8 +308,10 @@ export default class PageEditor extends React.Component {
             onScroll={this.onEditorScroll}
             onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
             onChange={this.onMarkdownChanged}
-            onSave={this.onSave}
             onUpload={this.onUpload}
+            onSave={() => {
+              this.props.onSaveWithShortcut(this.state.markdown);
+            }}
           />
         </div>
         <div className="col-md-6 hidden-sm hidden-xs page-editor-preview-container">
@@ -395,11 +331,11 @@ export default class PageEditor extends React.Component {
 PageEditor.propTypes = {
   crowi: PropTypes.object.isRequired,
   crowiRenderer: PropTypes.object.isRequired,
+  onSaveWithShortcut: PropTypes.func.isRequired,
   markdown: PropTypes.string.isRequired,
   pageId: PropTypes.string,
   revisionId: PropTypes.string,
   pagePath: PropTypes.string,
-  onSaveSuccess: PropTypes.func,
   editorOptions: PropTypes.instanceOf(EditorOptions),
   previewOptions: PropTypes.instanceOf(PreviewOptions),
 };

+ 1 - 5
resource/js/components/PageEditor/CodeMirrorEditor.js

@@ -114,9 +114,6 @@ export default class CodeMirrorEditor extends AbstractEditor {
   componentDidMount() {
     // ensure to be able to resolve 'this' to use 'codemirror.commands.save'
     this.getCodeMirror().codeMirrorEditor = this;
-
-    // initialize caret line
-    this.setCaretLine(0);
   }
 
   componentWillReceiveProps(nextProps) {
@@ -493,7 +490,6 @@ export default class CodeMirrorEditor extends AbstractEditor {
   renderSimpleCheatsheet() {
     return (
       <div className="panel panel-default gfm-cheatsheet mb-0">
-        <div className="panel-heading"><i className="icon-fw icon-question"/>Markdown Help</div>
         <div className="panel-body small p-b-0">
           <div className="row">
             <div className="col-xs-6">
@@ -517,7 +513,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
                 - リスト 1<br />
                 &nbsp;&nbsp;&nbsp;&nbsp;- リスト 1_1<br />
                 - リスト 2<br />
-                1. 番号付きリスト 1
+                1. 番号付きリスト 1<br />
                 1. 番号付きリスト 2
               </p>
               <hr />

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

@@ -32,11 +32,6 @@ export default class Editor extends AbstractEditor {
     this.renderDropzoneOverlay = this.renderDropzoneOverlay.bind(this);
   }
 
-  componentDidMount() {
-    // initialize caret line
-    this.setCaretLine(0);
-  }
-
   getEditorSubstance() {
     return this.props.isMobile
       ? this.refs.taEditor

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

@@ -1,6 +1,9 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import SplitButton from 'react-bootstrap/es/SplitButton';
+import MenuItem from 'react-bootstrap/es/MenuItem';
+
 import * as toastr from 'toastr';
 
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
@@ -11,12 +14,20 @@ export default class PageEditorByHackmd extends React.PureComponent {
     super(props);
 
     this.state = {
+      markdown: this.props.markdown,
+      isInitialized: false,
       isInitializing: false,
+      initialRevisionId: this.props.revisionId,
+      revisionId: this.props.revisionId,
+      revisionIdHackmdSynced: this.props.revisionIdHackmdSynced,
       pageIdOnHackmd: this.props.pageIdOnHackmd,
+      hasDraftOnHackmd: this.props.hasDraftOnHackmd,
     };
 
     this.getHackmdUri = this.getHackmdUri.bind(this);
-    this.startIntegrationWithHackmd = this.startIntegrationWithHackmd.bind(this);
+    this.startToEdit = this.startToEdit.bind(this);
+    this.resumeToEdit = this.resumeToEdit.bind(this);
+    this.hackmdEditorChangeHandler = this.hackmdEditorChangeHandler.bind(this);
 
     this.apiErrorHandler = this.apiErrorHandler.bind(this);
   }
@@ -24,19 +35,78 @@ 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, updateEditorValue = true) {
+    this.setState({ markdown });
+    if (this.state.isInitialized && updateEditorValue) {
+      this.refs.hackmdEditor.setValue(markdown);
+    }
   }
 
-  syncToLatestRevision() {
+  /**
+   * reset initialized status
+   */
+  reset() {
+    this.setState({ isInitialized: false });
+  }
 
+  /**
+   * clear revision status (invoked when page is updated by myself)
+   */
+  clearRevisionStatus(updatedRevisionId, updatedRevisionIdHackmdSynced) {
+    this.setState({
+      initialRevisionId: updatedRevisionId,
+      revisionId: updatedRevisionId,
+      revisionIdHackmdSynced: updatedRevisionIdHackmdSynced,
+      isDraftUpdatingInRealtime: false,
+    });
+  }
+
+  /**
+   * update revisionId of state
+   * @param {string} revisionId
+   * @param {string} revisionIdHackmdSynced
+   */
+  setRevisionId(revisionId, revisionIdHackmdSynced) {
+    this.setState({ revisionId, revisionIdHackmdSynced });
+  }
+
+  getRevisionIdHackmdSynced() {
+    return this.state.revisionIdHackmdSynced;
+  }
+
+  /**
+   * update hasDraftOnHackmd of state
+   * @param {bool} hasDraftOnHackmd
+   */
+  setHasDraftOnHackmd(hasDraftOnHackmd) {
+    this.setState({ hasDraftOnHackmd });
+  }
+
+  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 +114,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 +140,48 @@ export default class PageEditorByHackmd extends React.PureComponent {
       });
   }
 
+  /**
+   * Start to edit w/o any api request
+   */
+  resumeToEdit() {
+    this.setState({isInitialized: true});
+  }
+
+  /**
+   * Reset draft
+   */
+  discardChanges() {
+    this.setState({hasDraftOnHackmd: false});
+  }
+
+  /**
+   * onChange event of HackmdEditor handler
+   */
+  hackmdEditorChangeHandler(body) {
+    const hackmdUri = this.getHackmdUri();
+
+    if (hackmdUri == null) {
+      // do nothing
+      return;
+    }
+
+    // do nothing if contents are same
+    if (this.props.markdown === body) {
+      return;
+    }
+
+    const params = {
+      pageId: this.props.pageId,
+    };
+    this.props.crowi.apiPost('/hackmd.saveOnHackmd', params)
+      .then(res => {
+        // do nothing
+      })
+      .catch(err => {
+        // do nothing
+      });
+  }
+
   apiErrorHandler(error) {
     toastr.error(error.message, 'Error occured', {
       closeButton: true,
@@ -77,30 +196,100 @@ 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 isResume = isPageExistsOnHackmd && this.state.hasDraftOnHackmd;
+
+    if (this.state.isInitialized) {
       return (
-        <div className="hackmd-nopage d-flex justify-content-center align-items-center">
-          <div>
-            <p className="text-center">
-              <button className="btn btn-success btn-lg waves-effect waves-light" type="button"
-                  onClick={() => this.startIntegrationWithHackmd()} disabled={this.state.isInitializing}>
-                <span className="btn-label"><i className="fa fa-file-text-o"></i></span>
-                Start to edit with HackMD
-              </button>
-            </p>
-            <p className="text-center">Clone this page and start to edit with multiple peoples.</p>
+        <HackmdEditor
+          ref='hackmdEditor'
+          hackmdUri={hackmdUri}
+          pageIdOnHackmd={this.state.pageIdOnHackmd}
+          initializationMarkdown={isResume ? null : this.state.markdown}
+          onChange={this.hackmdEditorChangeHandler}
+          onSaveWithShortcut={(document) => {
+            this.props.onSaveWithShortcut(document);
+          }}
+        >
+        </HackmdEditor>
+      );
+    }
+
+    const isRevisionOutdated = this.state.initialRevisionId !== this.state.revisionId;
+    const isHackmdDocumentOutdated = this.state.revisionId !== this.state.revisionIdHackmdSynced;
+
+    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>
+      );
+    }
+    /*
+     * Resume to edit or discard changes
+     */
+    else if (isResume) {
+      const revisionIdHackmdSynced = this.state.revisionIdHackmdSynced;
+      const title = (
+        <React.Fragment>
+          <span className="btn-label"><i className="icon-control-end"></i></span>
+          Resume to edit with HackMD
+        </React.Fragment>);
+      content = (
+        <div>
+          <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
+          <div className="text-center hackmd-resume-button-container mb-3">
+            <SplitButton id='split-button-resume-hackmd' title={title} bsStyle="success" bsSize="large"
+                className="btn-resume waves-effect waves-light" onClick={() => this.resumeToEdit()}>
+              <MenuItem className="text-center" onClick={() => this.discardChanges()}>
+                <i className="icon-control-rewind"></i> Discard changes
+              </MenuItem>
+            </SplitButton>
           </div>
+          <p className="text-center">
+            Click to edit from the previous continuation<br />
+            or <button className="btn btn-link text-danger p-0 hackmd-discard-button" onClick={() => this.discardChanges()}>Discard changes</button>.
+          </p>
+          { isHackmdDocumentOutdated &&
+            <div className="panel panel-warning mt-5">
+              <div className="panel-heading"><i className="icon-fw icon-info"></i> DRAFT MAY BE OUTDATED</div>
+              <div className="panel-body text-center">
+                The current draft on HackMD is based on&nbsp;
+                <a href={`?revision=${revisionIdHackmdSynced}`}><span className="label label-default">{revisionIdHackmdSynced.substr(-8)}</span></a>.<br />
+                <button className="btn btn-link text-danger p-0 hackmd-discard-button" onClick={() => this.discardChanges()}>Discard it</button> to start to edit with current revision.
+              </div>
+            </div>
+          }
+        </div>
+      );
+    }
+    /*
+     * Start to edit
+     */
+    else {
+      content = (
+        <div>
+          <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
+          <div className="text-center hackmd-start-button-container mb-3">
+            <button className="btn btn-info btn-lg waves-effect waves-light" type="button" disabled={isRevisionOutdated || this.state.isInitializing}
+                onClick={() => this.startToEdit()}>
+              <span className="btn-label"><i className="icon-paper-plane"></i></span>
+              Start to edit with HackMD
+            </button>
+          </div>
+          <p className="text-center">Click to clone page content and start to edit.</p>
         </div>
       );
     }
 
     return (
-      <HackmdEditor
-        markdown={this.props.markdown}
-        hackmdUri={hackmdUri}
-        pageIdOnHackmd={this.state.pageIdOnHackmd}
-      >
-      </HackmdEditor>
+      <div className="hackmd-preinit d-flex justify-content-center align-items-center">
+        {content}
+      </div>
     );
   }
 }
@@ -108,8 +297,10 @@ export default class PageEditorByHackmd extends React.PureComponent {
 PageEditorByHackmd.propTypes = {
   crowi: PropTypes.object.isRequired,
   markdown: PropTypes.string.isRequired,
+  onSaveWithShortcut: PropTypes.func.isRequired,
   pageId: PropTypes.string,
   revisionId: PropTypes.string,
-  revisionIdHackmdSynced: PropTypes.string,
   pageIdOnHackmd: PropTypes.string,
+  revisionIdHackmdSynced: PropTypes.string,
+  hasDraftOnHackmd: PropTypes.bool,
 };

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

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

+ 143 - 0
resource/js/components/PageStatusAlert.jsx

@@ -0,0 +1,143 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { translate } from 'react-i18next';
+
+/**
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @export
+ * @class PageStatusAlert
+ * @extends {React.Component}
+ */
+
+class PageStatusAlert extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      initialRevisionId: this.props.revisionId,
+      revisionId: this.props.revisionId,
+      revisionIdHackmdSynced: this.props.revisionIdHackmdSynced,
+      lastUpdateUsername: undefined,
+      hasDraftOnHackmd: this.props.hasDraftOnHackmd,
+      isDraftUpdatingInRealtime: false,
+    };
+
+    this.renderSomeoneEditingAlert = this.renderSomeoneEditingAlert.bind(this);
+    this.renderDraftExistsAlert = this.renderDraftExistsAlert.bind(this);
+    this.renderUpdatedAlert = this.renderUpdatedAlert.bind(this);
+  }
+
+  /**
+   * clear status (invoked when page is updated by myself)
+   */
+  clearRevisionStatus(updatedRevisionId, updatedRevisionIdHackmdSynced) {
+    this.setState({
+      initialRevisionId: updatedRevisionId,
+      revisionId: updatedRevisionId,
+      revisionIdHackmdSynced: updatedRevisionIdHackmdSynced,
+      hasDraftOnHackmd: false,
+      isDraftUpdatingInRealtime: false,
+    });
+  }
+
+  setRevisionId(revisionId, revisionIdHackmdSynced) {
+    this.setState({ revisionId, revisionIdHackmdSynced });
+  }
+
+  setLastUpdateUsername(lastUpdateUsername) {
+    this.setState({ lastUpdateUsername });
+  }
+
+  setHasDraftOnHackmd(hasDraftOnHackmd) {
+    this.setState({
+      hasDraftOnHackmd,
+      isDraftUpdatingInRealtime: true,
+    });
+  }
+
+  renderSomeoneEditingAlert() {
+    return (
+      <div className="alert-hackmd-someone-editing myadmin-alert alert-success myadmin-alert-bottom alertbottom2">
+        <i className="icon-fw icon-people"></i>
+        Someone editing this page on HackMD
+        &nbsp;
+        <i className="fa fa-angle-double-right"></i>
+        &nbsp;
+        <a href="#hackmd">
+          Open HackMD Editor
+        </a>
+      </div>
+    );
+  }
+
+  renderDraftExistsAlert(isRealtime) {
+    return (
+      <div className="alert-hackmd-draft-exists myadmin-alert alert-success myadmin-alert-bottom alertbottom2">
+        <i className="icon-fw icon-pencil"></i>
+        This page has a draft on HackMD
+        &nbsp;
+        <i className="fa fa-angle-double-right"></i>
+        &nbsp;
+        <a href="#hackmd">
+          Open HackMD Editor
+        </a>
+      </div>
+    );
+  }
+
+  renderUpdatedAlert() {
+    const { t } = this.props;
+    const label1 = t('edited this page');
+    const label2 = t('Load latest');
+
+    return (
+      <div className="alert-revision-outdated myadmin-alert alert-warning myadmin-alert-bottom alertbottom2">
+        <i className="icon-fw icon-bulb"></i>
+        {this.state.lastUpdateUsername} {label1}
+        &nbsp;
+        <i className="fa fa-angle-double-right"></i>
+        &nbsp;
+        <a href="javascript:location.reload();">
+          {label2}
+        </a>
+      </div>
+    );
+  }
+
+  render() {
+    let content = <React.Fragment></React.Fragment>;
+
+    const isRevisionOutdated = this.state.initialRevisionId !== this.state.revisionId;
+    const isHackmdDocumentOutdated = this.state.revisionId !== this.state.revisionIdHackmdSynced;
+
+    if (isHackmdDocumentOutdated && isRevisionOutdated) {
+      content = this.renderUpdatedAlert();
+    }
+    else {
+      if (this.state.isDraftUpdatingInRealtime) {
+        content = this.renderSomeoneEditingAlert();
+      }
+      else if (this.state.hasDraftOnHackmd) {
+        content = this.renderDraftExistsAlert();
+      }
+    }
+
+    return content;
+  }
+}
+
+PageStatusAlert.propTypes = {
+  t: PropTypes.func.isRequired,               // i18next
+  crowi: PropTypes.object.isRequired,
+  hasDraftOnHackmd: PropTypes.bool.isRequired,
+  revisionId: PropTypes.string,
+  revisionIdHackmdSynced: PropTypes.string,
+};
+
+PageStatusAlert.defaultProps = {
+};
+
+export default translate()(PageStatusAlert);

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

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

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

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

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

+ 1 - 0
resource/js/i18n.js

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

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

+ 0 - 608
resource/js/legacy/crowi-form.js

@@ -1,608 +0,0 @@
-var pageId = $('#content-main').data('page-id');
-var pagePath= $('#content-main').data('path');
-
-require('bootstrap-select');
-
-// 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');
-  }
-}
-
-$('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');
     }

+ 175 - 350
resource/js/legacy/crowi.js

@@ -13,10 +13,10 @@ import { debounce } from 'throttle-debounce';
 import GrowiRenderer from '../util/GrowiRenderer';
 import Page from '../components/Page';
 
-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 +28,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
@@ -71,8 +67,7 @@ Crowi.setCaretLineData = function(line) {
 /**
  * invoked when;
  *
- * 1. window loaded
- * 2. 'shown.bs.tab' event fired
+ * 1. 'shown.bs.tab' event fired
  */
 Crowi.setCaretLineAndFocusToEditor = function() {
   // get 'data-caret-line' attributes
@@ -82,13 +77,10 @@ Crowi.setCaretLineAndFocusToEditor = function() {
     return;
   }
 
-  const line = pageEditorDom.getAttribute('data-caret-line');
-
-  if (line != null && !isNaN(line)) {
-    crowi.setCaretLine(+line);
-    // reset data-caret-line attribute
-    pageEditorDom.removeAttribute('data-caret-line');
-  }
+  const line = pageEditorDom.getAttribute('data-caret-line') || 0;
+  crowi.setCaretLine(+line);
+  // reset data-caret-line attribute
+  pageEditorDom.removeAttribute('data-caret-line');
 
   // focus
   crowi.focusToEditor();
@@ -104,30 +96,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 +128,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) {
@@ -167,6 +154,28 @@ Crowi.handleKeyCtrlSlashHandler = (event) => {
   event.preventDefault();
 };
 
+Crowi.initAffix = () => {
+  const $affixContent = $('#page-header');
+  if ($affixContent.length > 0) {
+    const $affixContentContainer = $('.row.bg-title');
+    const containerHeight = $affixContentContainer.outerHeight(true);
+    $affixContent.affix({
+      offset: {
+        top: function() {
+          return $('.navbar').outerHeight(true) + containerHeight;
+        }
+      }
+    });
+    $('[data-affix-disable]').on('click', function(e) {
+      const $elm = $($(this).data('affix-disable'));
+      $(window).off('.affix');
+      $elm.removeData('affix').removeClass('affix affix-top affix-bottom');
+      return false;
+    });
+    $affixContentContainer.css({'min-height': containerHeight});
+  }
+};
+
 Crowi.initSlimScrollForRevisionToc = () => {
   const revisionTocElem = document.querySelector('.growi .revision-toc');
   const tocContentElem = document.querySelector('.growi .revision-toc .markdownIt-TOC');
@@ -221,23 +230,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 +329,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 +341,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 +356,7 @@ $(function() {
   });
 
   $('#create-page-under-tree').submit(function(e) {
-    var name = $('input', this).val();
+    let name = $('input', this).val();
     if (!name.match(/^\//)) {
       name = '/' + name;
     }
@@ -310,10 +379,12 @@ $(function() {
       nameValueMap[obj.name] = obj.value;
     });
 
+    const data = $(this).serialize() + `&socketClientId=${crowi.getSocketClientId()}`;
+
     $.ajax({
       type: 'POST',
       url: '/_api/pages.rename',
-      data: $(this).serialize(),
+      data: data,
       dataType: 'json'
     })
     .done(function(res) {
@@ -325,7 +396,7 @@ $(function() {
         `);
       }
       else {
-        var page = res.page;
+        const page = res.page;
         top.location.href = page.path + '?renamed=' + pagePath;
       }
     });
@@ -359,7 +430,7 @@ $(function() {
         `);
       }
       else {
-        var page = res.page;
+        const page = res.page;
         top.location.href = page.path + '?duplicated=' + pagePath;
       }
     });
@@ -380,7 +451,7 @@ $(function() {
         $('#delete-errors').addClass('alert-danger');
       }
       else {
-        var page = res.page;
+        const page = res.page;
         top.location.href = page.path;
       }
     });
@@ -399,7 +470,7 @@ $(function() {
         $('#delete-errors').addClass('alert-danger');
       }
       else {
-        var page = res.page;
+        const page = res.page;
         top.location.href = page.path;
       }
     });
@@ -419,7 +490,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 +501,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 +520,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 +533,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 +541,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 +549,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,223 +570,20 @@ $(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');
-    if ($affixContent.length > 0) {
-      var $affixContentContainer = $('.row.bg-title');
-      var containerHeight = $affixContentContainer.outerHeight(true);
-      $affixContent.affix({
-        offset: {
-          top: function() {
-            return $('.navbar').outerHeight(true) + containerHeight;
-          }
-        }
-      });
-      $('[data-affix-disable]').on('click', function(e) {
-        var $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 +601,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 +630,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 +654,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');
@@ -806,18 +674,27 @@ $(function() {
       $b.toggleClass('overlay-on');
     });
 
-    //
-    var me = $('body').data('me');
-    var socket = io();
-    socket.on('page edited', function(data) {
-      if (data.user._id != me
-        && data.page.path == pagePath) {
-        $('#notifPageEdited').show();
-        $('#notifPageEdited .edited-user').html(data.user.name);
-      }
-    });
   } // 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 +740,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 +757,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 +774,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');
@@ -969,8 +794,8 @@ window.addEventListener('load', function(e) {
 
   Crowi.highlightSelectedSection(location.hash);
   Crowi.modifyScrollTop();
-  Crowi.setCaretLineAndFocusToEditor();
   Crowi.initSlimScrollForRevisionToc();
+  Crowi.initAffix();
 });
 
 window.addEventListener('hashchange', function(e) {

+ 35 - 5
resource/js/util/Crowi.js

@@ -16,7 +16,6 @@ export default class Crowi {
   constructor(context, window) {
     this.context = context;
     this.config = {};
-    this.csrfToken = context.csrfToken;
 
     const userAgent = window.navigator.userAgent.toLowerCase();
     this.isMobile = /iphone|ipad|android/.test(userAgent);
@@ -25,6 +24,7 @@ export default class Crowi {
     this.location = window.location || {};
     this.document = window.document || {};
     this.localStorage = window.localStorage || {};
+    this.socketClientId = Math.floor(Math.random() * 100000);
     this.pageEditor = undefined;
 
     this.fetchUsers = this.fetchUsers.bind(this);
@@ -39,6 +39,7 @@ export default class Crowi {
     // FIXME
     this.me = context.me;
     this.isAdmin = context.isAdmin;
+    this.csrfToken = context.csrfToken;
 
     this.users = [];
     this.userByName = {};
@@ -56,10 +57,6 @@ export default class Crowi {
     return window.Crowi;
   }
 
-  getContext() {
-    return this.context;
-  }
-
   setConfig(config) {
     this.config = config;
   }
@@ -72,6 +69,10 @@ export default class Crowi {
     this.pageEditor = pageEditor;
   }
 
+  getSocketClientId() {
+    return this.socketClientId;
+  }
+
   getEmojiStrategy() {
     return emojiStrategy;
   }
@@ -196,6 +197,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;
+}

+ 99 - 46
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
@@ -29,7 +23,7 @@ body.on-edit {
   .row.page-comments-row,
   .row.page-attachments-row,
   .row.not-found-message-row,
-  .users-meta,
+  .users-info,
   .user-page-content-container,
   .portal-form-button,
   .alert-info.alert-moved,
@@ -59,6 +53,13 @@ body.on-edit {
     display: none;
   }
 
+  // hide hackmd related alert
+  &.hackmd #page-status-alert {
+    .alert-hackmd-someone-editing, .alert-hackmd-draft-exists {
+      display: none;
+    }
+  }
+
   /*****************
    * Expand Editor
    *****************/
@@ -85,7 +86,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 +178,30 @@ 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;
-        }
-      }
+  #page-status-alert .myadmin-alert {
+    position: absolute;
+    opacity: 0.8;
+    bottom: 42px;
+    right: 3px;
+    left: 3px;
+    box-shadow: 2px 2px 5px #666;
+
+    &:hover {
+      opacity: 1;
     }
+  }
+
+  &.builtin-editor {
 
     /*****************
     * Editor styles
@@ -240,6 +274,12 @@ body.on-edit {
         min-width: 150px;
       }
     }
+
+    #page-grant-selector {
+      .btn-group {
+        min-width: 150px;
+      }
+    }
   } // .builtin-editor .tab-pane#edit
 
 
@@ -248,18 +288,31 @@ body.on-edit {
       display: none;
     }
 
-    .tab-pane#hackmd {
-      @extend %expand-by-flex;
+    .hackmd-preinit, #iframe-hackmd-container > iframe {
+      border: none;
+    }
 
-      #hackmd-editor,
-      .hackmd-nopage, #iframe-hackmd {
-        width: 100vw;
-        min-height: calc(100vh - #{$header-plus-footer});   // for IE11
-        height: calc(100vh - #{$header-plus-footer});
+    .hackmd-status-label {
+      font-size: 3em;
+      color: $muted;
+    }
 
-        border: none;
+    .hackmd-start-button-container, .hackmd-resume-button-container {
+      .btn-lg .btn-label {
+        padding-top: 6px;     // for SplitButton
+        padding-bottom: 6px;  // for SplitButton
       }
     }
+    .hackmd-resume-button-container {
+      .dropdown-menu {
+        left: unset;
+        right: 0;
+      }
+    }
+    .hackmd-discard-button {
+      vertical-align: unset;
+      text-decoration: underline;
+    }
 
   }
 

+ 4 - 0
resource/styles/scss/_page.scss

@@ -98,6 +98,10 @@
     padding: 10px 15px;
   }
 
+  // show PageStatusAlert in default
+  #page-status-alert .myadmin-alert {
+    display: block;
+  }
 }
 
 .main-container .main .content-main .revision-history { // {{{

+ 26 - 0
resource/styles/scss/_user.scss

@@ -1,7 +1,16 @@
 .main-container {
   .user-page-header {
 
+    #revision-path {
+      margin-bottom: 0;
+    }
+
+    .users-meta {
+      margin-left: 30px;
+    }
+
     h1 {
+      margin: 0;
       font-size: 2.5em;
       color: #666;
     }
@@ -35,7 +44,24 @@
         padding: 8px;
       }
     }
+  }
+
+  // affix
+  .user-page-header.affix {
 
+    .users-meta {
+      margin-left: 15px;
+    }
+
+    h1 {
+      font-size: 1.5em;
+      line-height: 30px;
+    }
+
+    .picture {
+      height: 48px;
+      width: 48px;
+    }
   }
 }
 

+ 29 - 0
yarn.lock

@@ -6227,6 +6227,14 @@ passport-local@^1.0.0:
   dependencies:
     passport-strategy "1.x.x"
 
+passport-oauth1@1.x.x:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/passport-oauth1/-/passport-oauth1-1.1.0.tgz#a7de988a211f9cf4687377130ea74df32730c918"
+  dependencies:
+    oauth "0.9.x"
+    passport-strategy "1.x.x"
+    utils-merge "1.x.x"
+
 passport-oauth2@1.x.x:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.4.0.tgz#f62f81583cbe12609be7ce6f160b9395a27b86ad"
@@ -6240,6 +6248,13 @@ passport-strategy@1.x, passport-strategy@1.x.x, passport-strategy@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4"
 
+passport-twitter@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/passport-twitter/-/passport-twitter-1.0.4.tgz#01a799e1f760bf2de49f2ba5fba32282f18932d7"
+  dependencies:
+    passport-oauth1 "1.x.x"
+    xtraverse "0.1.x"
+
 passport@^0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/passport/-/passport-0.4.0.tgz#c5095691347bd5ad3b5e180238c3914d16f05811"
@@ -6333,6 +6348,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"
@@ -8964,6 +8983,10 @@ xmlbuilder@4.2.1, xmlbuilder@^4.1.0:
   dependencies:
     lodash "^4.0.0"
 
+xmldom@0.1.x:
+  version "0.1.27"
+  resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.27.tgz#d501f97b3bdb403af8ef9ecc20573187aadac0e9"
+
 xmlhttprequest-ssl@~1.5.4:
   version "1.5.4"
   resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.4.tgz#04f560915724b389088715cc0ed7813e9677bf57"
@@ -8992,6 +9015,12 @@ xtend@~2.1.1:
   dependencies:
     object-keys "~0.4.0"
 
+xtraverse@0.1.x:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/xtraverse/-/xtraverse-0.1.0.tgz#b741bad018ef78d8a9d2e83ade007b3f7959c732"
+  dependencies:
+    xmldom "0.1.x"
+
 y18n@^3.2.0, y18n@^3.2.1:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41"