Ver código fonte

Merge branch 'master' into feat/auto-tempalte-UI

# Conflicts:
#	lib/models/page.js
#	lib/routes/page.js
#	lib/views/widget/page_tabs.html
sou 7 anos atrás
pai
commit
e078ea7cfb
52 arquivos alterados com 1730 adições e 992 exclusões
  1. 0 2
      .gitignore
  2. 34 0
      .vscode/launch.json
  3. 4 0
      .vscode/settings.json
  4. 19 0
      .vscode/tasks.json
  5. 25 3
      CHANGES.md
  6. 4 1
      app.json
  7. 1 1
      config/logger/config.prod.js
  8. 10 0
      config/webpack.common.js
  9. 7 3
      lib/crowi/dev.js
  10. 1 0
      lib/form/revision.js
  11. 1 0
      lib/locales/en-US/translation.json
  12. 2 0
      lib/locales/index.js
  13. 1 0
      lib/locales/ja/translation.json
  14. 18 52
      lib/models/page-group-relation.js
  15. 88 107
      lib/models/page.js
  16. 0 12
      lib/models/user-group-relation.js
  17. 4 3
      lib/routes/index.js
  18. 14 2
      lib/routes/me.js
  19. 65 60
      lib/routes/page.js
  20. 5 44
      lib/views/_form.html
  21. 2 0
      lib/views/admin/customize.html
  22. 1 0
      lib/views/admin/widget/theme-colorbox.html
  23. 0 1
      lib/views/layout-growi/not_found.html
  24. 2 0
      lib/views/layout/layout.html
  25. 0 23
      lib/views/modal/select_grant_group.html
  26. 9 3
      lib/views/widget/page_alerts.html
  27. 1 1
      lib/views/widget/page_list.html
  28. 0 1
      lib/views/widget/page_modals.html
  29. 10 2
      lib/views/widget/page_tabs.html
  30. 8 4
      package.json
  31. 30 27
      resource/js/app.js
  32. 5 3
      resource/js/components/Page.js
  33. 1 0
      resource/js/components/PageEditor.js
  34. 104 0
      resource/js/components/PageEditor/AbstractEditor.js
  35. 438 0
      resource/js/components/PageEditor/CodeMirrorEditor.js
  36. 76 349
      resource/js/components/PageEditor/Editor.js
  37. 200 112
      resource/js/components/PageEditor/GrantSelector.js
  38. 24 53
      resource/js/components/PageEditor/MarkdownListUtil.js
  39. 13 6
      resource/js/components/PageEditor/MarkdownTableInterceptor.js
  40. 1 10
      resource/js/components/PageEditor/MarkdownTableUtil.js
  41. 5 4
      resource/js/components/PageEditor/PreventMarkdownListInterceptor.js
  42. 224 0
      resource/js/components/PageEditor/TextAreaEditor.js
  43. 44 0
      resource/js/i18n.js
  44. 2 2
      resource/js/legacy/crowi.js
  45. 4 1
      resource/js/util/Crowi.js
  46. 54 0
      resource/styles/agile-admin/inverse/colors/blue-night.scss
  47. 36 0
      resource/styles/agile-admin/inverse/colors/future.scss
  48. 30 15
      resource/styles/scss/_on-edit.scss
  49. 11 3
      resource/styles/scss/_wiki.scss
  50. 8 0
      resource/styles/scss/theme/blue-night.scss
  51. 8 0
      resource/styles/scss/theme/future.scss
  52. 76 82
      yarn.lock

+ 0 - 2
.gitignore

@@ -34,5 +34,3 @@ package-lock.json
 
 # IDE #
 .idea
-.vscode
-

+ 34 - 0
.vscode/launch.json

@@ -0,0 +1,34 @@
+{
+    // IntelliSense を使用して利用可能な属性を学べます。
+    // 既存の属性の説明をホバーして表示します。
+    // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387
+    "version": "0.2.0",
+    "configurations": [
+      {
+        "type": "node",
+        "request": "launch",
+        "name": "Debug: Server",
+        "runtimeExecutable": "npm",
+        "runtimeArgs": [
+          "run",
+          "server:debug"
+        ],
+        "port": 9229,
+        "restart": true,
+        "console": "integratedTerminal",
+        "internalConsoleOptions": "neverOpen"
+      },
+      {
+        "type": "chrome",
+        "request": "launch",
+        "trace": true,
+        "name": "Debug: Chrome",
+        "sourceMaps": true,
+        "sourceMapPathOverrides": {
+          "webpack:///*": "${workspaceRoot}/*"
+        },
+        "url": "http://localhost:3000",
+        "webRoot": "${workspaceRoot}/public"
+      }
+    ]
+}

+ 4 - 0
.vscode/settings.json

@@ -0,0 +1,4 @@
+{
+  // 既定の改行文字。LF の場合には \n を CRLF の場合には \r\n を使用してください。
+  "files.eol": "\n",
+}

+ 19 - 0
.vscode/tasks.json

@@ -0,0 +1,19 @@
+{
+    // See https://go.microsoft.com/fwlink/?LinkId=733558
+    // for the documentation about the tasks.json format
+    "version": "2.0.0",
+    "tasks": [
+        {
+            "type": "npm",
+            "script": "build",
+            "problemMatcher": [
+                "$eslint-compact"
+            ]
+        },
+        {
+            "type": "npm",
+            "script": "server",
+            "problemMatcher": []
+        }
+    ]
+}

+ 25 - 3
CHANGES.md

@@ -1,17 +1,39 @@
 CHANGES
 ========
 
-## 3.1.0-RC
+## 3.1.2-RC
+
+* Improvement: Add 'future' theme
+* Fix: Posting to Slack doesn't work
+    * Introduced by 3.1.0
+* Support: Upgrade libs
+    * assets-webpack-plugin
+    * react-clipboard.js
+    * xss
+
+## 3.1.1-RC
+
+* Improvement: Add 'blue-night' theme
+* Improvement: List up pages which restricted for Group ACL
+* Fix: PageGroupRelation didn't remove when page is removed completely
+
+
+## 3.1.0
 
 * Improvement: Group Access Control List - Select group modal
-* Improvement: Auto-format markdown tables which includes multibyte text
+* Improvement: Better input on mobile
+* Improvement: Detach code blocks correctly
+* Improvement: Auto-format markdown table which includes multibyte text
+* Improvement: Show icon when auto-format markdown table is activated
 * Improvement: Enable to switch show/hide border for highlight.js
 * Improvement: BindDN field allows also ActiveDirectory styles 
 * Improvement: Show LDAP logs when testing login
-* Improvement: Detach code blocks correctly
 * Fix: Comment body doesn't break long terms
 * Fix: lsx plugin lists up pages that hit by forward match wrongly
     * Introduced by 3.0.4
+* Fix: Editor is broken on IE11
+* Support: Multilingualize React components with i18next
+* Support: Organize dependencies
 * Support: Upgrade libs
     * elasticsearch
     * googleapis

+ 4 - 1
app.json

@@ -8,7 +8,10 @@
   "repository": "https://github.com/weseek/growi",
   "success_url": "/",
   "env": {
-    "NODE_ENV": "development",
+    "NODE_ENV": {
+      "description": "DO NOT CHANGE - 'yarn' needs this to install devDependencies",
+      "value": "development"
+    },
     "SECRET_TOKEN": {
       "description": "A secret key for verifying the integrity of signed cookies.",
       "generator": "secret"

+ 1 - 1
config/logger/config.prod.js

@@ -1,3 +1,3 @@
 module.exports = {
-  default: 'info',
+  default: 'warn',
 };

+ 10 - 0
config/webpack.common.js

@@ -30,6 +30,8 @@ module.exports = function(options) {
       'style-theme-default-dark':  './resource/styles/scss/theme/default-dark.scss',
       'style-theme-nature':   './resource/styles/scss/theme/nature.scss',
       'style-theme-mono-blue':   './resource/styles/scss/theme/mono-blue.scss',
+      'style-theme-future': './resource/styles/scss/theme/future.scss',
+      'style-theme-blue-night': './resource/styles/scss/theme/blue-night.scss',
       'style-presentation':   './resource/styles/scss/style-presentation.scss',
     },
     externals: {
@@ -45,6 +47,7 @@ module.exports = function(options) {
       alias: {
         '@root': helpers.root('/'),
         '@alias/logger': helpers.root('lib/service/logger'),
+        '@alias/locales': helpers.root('lib/locales'),
         // replace bunyan
         'bunyan': 'browser-bunyan',
       }
@@ -67,6 +70,13 @@ module.exports = function(options) {
             }
           }]
         },
+        {
+          test: /locales/,
+          loader: '@alienfast/i18next-loader',
+          options: {
+            basenameAsNamespace: true,
+          }
+        },
         {
           test: /\.css$/,
           use: ['style-loader', 'css-loader'],

+ 7 - 3
lib/crowi/dev.js

@@ -41,9 +41,13 @@ class CrowiDev {
    */
   requireForAutoReloadServer() {
     // load all json files for live reloading
-    fs.readdirSync(this.crowi.localeDir).map((dirname) => {
-      require(path.join(this.crowi.localeDir, dirname, 'translation.json'));
-    });
+    fs.readdirSync(this.crowi.localeDir)
+      .filter(filename => {
+        return fs.statSync(path.join(this.crowi.localeDir, filename)).isDirectory();
+      })
+      .map((dirname) => {
+        require(path.join(this.crowi.localeDir, dirname, 'translation.json'));
+      });
   }
 
   /**

+ 1 - 0
lib/form/revision.js

@@ -8,5 +8,6 @@ module.exports = form(
   field('pageForm.body').required().custom(function(value) { return value.replace(/\r/g, '\n') }),
   field('pageForm.currentRevision'),
   field('pageForm.grant').toInt().required(),
+  field('pageForm.grantUserGroupId'),
   field('pageForm.notify')
 );

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

@@ -93,6 +93,7 @@
   "Specified users only": "Specified users only",
   "Just me": "Just me",
   "Only inside the group": "Only inside the group",
+  "Reselect the group": "Reselect the group",
   "Shareable link": "Shareable link",
 
   "Show latest": "Show latest",

+ 2 - 0
lib/locales/index.js

@@ -0,0 +1,2 @@
+// !!DO NOT REMOVE THIS FILE!!
+// entry point for @alienfast/i18next-loader

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

@@ -107,6 +107,7 @@
   "Specified users": "特定ユーザーのみ",
   "Just me": "自分のみ",
   "Only inside the group": "特定グループのみ",
+  "Reselect the group": "グループの再選択",
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Selecting authentication mechanism": "認証機構選択",

+ 18 - 52
lib/models/page-group-relation.js

@@ -88,21 +88,18 @@ class PageGroupRelation {
    * @returns {Promise<any>} mongoose-paginate result object
    * @memberof UserGroupRelation
    */
-  static findPageGroupRelationsWithPagination(userGroup, opts) {
-    const query = { relatedGroup: userGroup };
-    const options = Object.assign({}, opts);
-    if (options.page == null) {
-      options.page = 1;
-    }
-    if (options.limit == null) {
-      options.limit = UserGroupRelation.PAGE_ITEMS;
-    }
-
-    return this.paginate(query, options)
-      .catch((err) => {
-        debug('Error on pagination:', err);
-      });
-  }
+  // static findPageGroupRelationsWithPagination(userGroup, opts) {
+  //   const query = { relatedGroup: userGroup };
+  //   const options = Object.assign({}, opts);
+  //   if (options.page == null) {
+  //     options.page = 1;
+  //   }
+  //   if (options.limit == null) {
+  //     options.limit = UserGroupRelation.PAGE_ITEMS;
+  //   }
+
+  //   return this.paginate(query, options);
+  // }
 
   /**
    * find the relation or create(if not exists) for page and group
@@ -126,10 +123,6 @@ class PageGroupRelation {
         else {
           return this.createRelation(userGroup, page);
         }
-      })
-      .catch((err) => {
-        debug('An Error occured.', err);
-        return reject(err);
       });
   }
 
@@ -147,7 +140,7 @@ class PageGroupRelation {
       return null;
     }
     return this
-      .find({ targetPage: page.id })
+      .findOne({ targetPage: page.id })
       .populate('relatedGroup')
       .exec();
   }
@@ -165,25 +158,8 @@ class PageGroupRelation {
     var UserGroupRelation = PageGroupRelation.crowi.model('UserGroupRelation');
 
     return this.findByPage(pageData)
-      .then((pageRelations) => {
-        return pageRelations.map((pageRelation) => {
-          return UserGroupRelation.isRelatedUserForGroup(userData, pageRelation.relatedGroup);
-        });
-      })
-      .then((checkPromises) => {
-        return Promise.all(checkPromises);
-      })
-      .then((checkResults) => {
-        var checkResult = false;
-        checkResults.map((result) => {
-          if (result) {
-            checkResult = true;
-          }
-        });
-        return checkResult;
-      })
-      .catch((err) => {
-        return reject(err);
+      .then(pageRelation => {
+        return UserGroupRelation.isRelatedUserForGroup(userData, pageRelation.relatedGroup);
       });
   }
 
@@ -237,15 +213,9 @@ class PageGroupRelation {
   static removeAllByPage(page) {
 
     return this.findByPage(page)
-      .then((relations) => {
-        debug('remove relations are ', relations);
-        if (relations == null) {
-          return;
-        }
-        else {
-          relations.map((relation) => {
-            relation.remove();
-          });
+      .then(relation => {
+        if (relation != null) {
+          relation.remove();
         }
       });
   }
@@ -268,10 +238,6 @@ class PageGroupRelation {
         else {
           relationData.remove();
         }
-      })
-      .catch((err) => {
-        debug('Error on find a removing page-group-relation', err);
-        return reject(err);
       });
   }
 }

+ 88 - 107
lib/models/page.js

@@ -344,7 +344,7 @@ module.exports = function(crowi) {
     grantLabels[GRANT_PUBLIC]     = 'Public'; // 公開
     grantLabels[GRANT_RESTRICTED] = 'Anyone with the link'; // リンクを知っている人のみ
     //grantLabels[GRANT_SPECIFIED]  = 'Specified users only'; // 特定ユーザーのみ
-    // grantLabels[GRANT_USER_GROUP] = 'Only inside the group'; // 特定グループのみ
+    grantLabels[GRANT_USER_GROUP] = 'Only inside the group'; // 特定グループのみ
     grantLabels[GRANT_OWNER]      = 'Just me'; // 自分のみ
 
     return grantLabels;
@@ -507,8 +507,8 @@ module.exports = function(crowi) {
 
         if (!pageData.isGrantedFor(userData)) {
           PageGroupRelation.isExistsGrantedGroupForPageAndUser(pageData, userData)
-            .then(function(checkResult) {
-              if (!checkResult) {
+            .then(isExists => {
+              if (!isExists) {
                 return reject(new Error('Page is not granted for the user')); //PAGE_GRANT_ERROR, null);
               }
               else {
@@ -527,6 +527,7 @@ module.exports = function(crowi) {
     });
   };
 
+<<<<<<< HEAD
   // check if a given page has a local and global tempalte
   pageSchema.statics.checkIfTemplatesExist = function(path) {
     const Page = this;
@@ -554,7 +555,7 @@ module.exports = function(crowi) {
     const Page = this;
     const templatePath = cutOffLastSlash(path);
     const pathList = generatePathsOnTree(templatePath, []);
-    const regexpList = pathList.map(path => new RegExp(`${path}/_{1,2}template`));
+    const regexpList = pathList.map(path => new RegExp(`^${path}/_{1,2}template$`));
 
     return Page
       .find({path: {$in: regexpList}})
@@ -576,6 +577,10 @@ module.exports = function(crowi) {
 
     pathList.push(path);
     const newPath = cutOffLastSlash(path);
+<<<<<<< HEAD
+=======
+
+>>>>>>> master
     return generatePathsOnTree(newPath, pathList);
   };
 
@@ -605,7 +610,7 @@ module.exports = function(crowi) {
     let templateBody;
     /**
      * get local template
-     * @tempate: applicable only to immediate decendants
+     * __tempate: applicable only to immediate decendants
      */
     const localTemplate = assignTemplateByType(templates, templatePath, '__');
 
@@ -627,17 +632,10 @@ module.exports = function(crowi) {
 
   // find page by path
   pageSchema.statics.findPageByPath = function(path) {
-    var Page = this;
-
-    return new Promise(function(resolve, reject) {
-      Page.findOne({path: path}, function(err, pageData) {
-        if (err || pageData === null) {
-          return reject(err);
-        }
-
-        return resolve(pageData);
-      });
-    });
+    if (path == null) {
+      return null;
+    }
+    return this.findOne({path});
   };
 
   pageSchema.statics.findListByPageIds = function(ids, options) {
@@ -865,6 +863,7 @@ module.exports = function(crowi) {
         {grant: GRANT_RESTRICTED, grantedUsers: userData._id},
         {grant: GRANT_SPECIFIED, grantedUsers: userData._id},
         {grant: GRANT_OWNER, grantedUsers: userData._id},
+        {grant: GRANT_USER_GROUP},
       ], })
       .and({
         $or: pathCondition
@@ -899,10 +898,11 @@ module.exports = function(crowi) {
   pageSchema.statics.updateGrant = function(page, grant, userData, grantUserGroupId) {
     var Page = this;
 
-    if (grant == GRANT_USER_GROUP && grantUserGroupId == null) {
-      throw new Error('grant userGroupId is not specified');
-    }
     return new Promise(function(resolve, reject) {
+      if (grant == GRANT_USER_GROUP && grantUserGroupId == null) {
+        reject('grant userGroupId is not specified');
+      }
+
       page.grant = grant;
       if (grant == GRANT_PUBLIC || grant == GRANT_USER_GROUP) {
         page.grantedUsers = [];
@@ -936,12 +936,12 @@ module.exports = function(crowi) {
       return UserGroupRelation.findByGroupIdAndUser(grantUserGroupId, userData)
       .then((relation) => {
         if (relation == null) {
-          return reject(new Error('no relations were exist for group and user.'));
+          return new Error('no relations were exist for group and user.');
         }
         return PageGroupRelation.findOrCreateRelationForPageAndGroup(page, relation.relatedGroup);
       })
       .catch((err) => {
-        return reject(new Error('No UserGroup is exists. userGroupId : ', grantUserGroupId));
+        return new Error('No UserGroup is exists. userGroupId : ', grantUserGroupId);
       });
     }
     else {
@@ -1014,10 +1014,11 @@ module.exports = function(crowi) {
       grant = GRANT_PUBLIC;
     }
 
-    return new Promise(function(resolve, reject) {
-      Page.findOne({path: path}, function(err, pageData) {
+    let savedPage = undefined;
+    return Page.findOne({path: path})
+      .then(pageData => {
         if (pageData) {
-          return reject(new Error('Cannot create new page to existed path'));
+          throw new Error('Cannot create new page to existed path');
         }
 
         var newPage = new Page();
@@ -1032,28 +1033,22 @@ module.exports = function(crowi) {
         newPage.grantedUsers = [];
         newPage.grantedUsers.push(user);
 
-        newPage.save(function(err, newPage) {
-          if (err) {
-            return reject(err);
-          }
-
-          if (newPage.grant == Page.GRANT_USER_GROUP && grantUserGroupId != null) {
-            Page.updateGrantUserGroup(newPage, grant, grantUserGroupId, user)
-              .catch((err) => {
-                return reject(err);
-              });
-          }
-          var newRevision = Revision.prepareRevision(newPage, body, user, {format: format});
-          Page.pushRevision(newPage, newRevision, user).then(function(data) {
-            resolve(data);
-            pageEvent.emit('create', data, user);
-          }).catch(function(err) {
-            debug('Push Revision Error on create page', err);
-            return reject(err);
-          });
-        });
+        return newPage.save();
+      })
+      .then((newPage) => {
+        savedPage = newPage;
+      })
+      .then(() => {
+        const newRevision = Revision.prepareRevision(savedPage, body, user, {format: format});
+        return Page.pushRevision(savedPage, newRevision, user);
+      })
+      .then(() => {
+        return Page.updateGrantUserGroup(savedPage, grant, grantUserGroupId, user);
+      })
+      .then(() => {
+        pageEvent.emit('create', savedPage, user);
+        return savedPage;
       });
-    });
   };
 
   pageSchema.statics.updatePage = function(pageData, body, user, options) {
@@ -1065,25 +1060,23 @@ module.exports = function(crowi) {
     // update existing page
     var newRevision = Revision.prepareRevision(pageData, body, user);
 
-    return new Promise(function(resolve, reject) {
-      Page.pushRevision(pageData, newRevision, user)
-      .then(function(revision) {
-        if (grant != pageData.grant) {
-          return Page.updateGrant(pageData, grant, user, grantUserGroupId).then(function(data) {
-            debug('Page grant update:', data);
-            resolve(data);
-            pageEvent.emit('update', data, user);
-          });
-        }
-        else {
-          resolve(pageData);
-          pageEvent.emit('update', pageData, user);
-        }
-      }).catch(function(err) {
-        debug('Error on update', err);
-        debug('Error on update', err.stack);
+    let savedPage = undefined;
+    return Page.pushRevision(pageData, newRevision, user)
+      .then((revision) => {
+        // fetch Page
+        return Page.findPageByPath(revision.path).populate('revision');
+      })
+      .then((page) => {
+        savedPage = page;
+      })
+      .then(() => {
+        return Page.updateGrant(savedPage, grant, user, grantUserGroupId);
+      })
+      .then((data) => {
+        debug('Page grant update:', data);
+        pageEvent.emit('update', savedPage, user);
+        return savedPage;
       });
-    });
   };
 
   pageSchema.statics.deletePage = function(pageData, user, options) {
@@ -1091,20 +1084,13 @@ module.exports = function(crowi) {
       , newPath = Page.getDeletedPageName(pageData.path)
       ;
     if (Page.isDeletableName(pageData.path)) {
-      return new Promise(function(resolve, reject) {
-        Page.updatePageProperty(pageData, {status: STATUS_DELETED, lastUpdateUser: user})
-        .then(function(data) {
-          pageData.status = STATUS_DELETED;
-
-          // ページ名が /trash/ 以下に存在する場合、おかしなことになる
-          // が、 /trash 以下にページが有るのは、個別に作っていたケースのみ。
-          // 一応しばらく前から uncreatable pages になっているのでこれでいいことにする
-          debug('Deleted the page, and rename it', pageData.path, newPath);
-          return Page.rename(pageData, newPath, user, {createRedirectPage: true});
-        }).then(function(pageData) {
-          resolve(pageData);
-        }).catch(reject);
-      });
+      return Page.rename(pageData, newPath, user, {createRedirectPage: true})
+        .then((updatedPageData) => {
+          return Page.updatePageProperty(updatedPageData, {status: STATUS_DELETED, lastUpdateUser: user});
+        })
+        .then(() => {
+          return pageData;
+        });
     }
     else {
       return Promise.reject('Page is not deletable.');
@@ -1117,18 +1103,15 @@ module.exports = function(crowi) {
       , options = options || {}
       ;
 
-    return new Promise(function(resolve, reject) {
-      Page
-      .generateQueryToListWithDescendants(path, user, options)
+    return Page.generateQueryToListWithDescendants(path, user, options)
       .then(function(pages) {
-        Promise.all(pages.map(function(page) {
+        return Promise.all(pages.map(function(page) {
           return Page.deletePage(page, user, options);
-        }))
-        .then(function(data) {
-          return resolve(pageData);
-        });
+        }));
+      })
+      .then(function(data) {
+        return pageData;
       });
-    });
 
   };
 
@@ -1171,6 +1154,7 @@ module.exports = function(crowi) {
     return new Promise(function(resolve, reject) {
       Page
         .generateQueryToListWithDescendants(path, user, options)
+        .exec()
         .then(function(pages) {
           Promise.all(pages.map(function(page) {
             return Page.revertDeletedPage(page, user, options);
@@ -1191,6 +1175,7 @@ module.exports = function(crowi) {
       , Attachment = crowi.model('Attachment')
       , Comment = crowi.model('Comment')
       , Revision = crowi.model('Revision')
+      , PageGroupRelation = crowi.model('PageGroupRelation')
       , Page = this
       , pageId = pageData._id
       ;
@@ -1210,6 +1195,8 @@ module.exports = function(crowi) {
         return Page.removePageById(pageId);
       }).then(function(done) {
         return Page.removeRedirectOriginPageByPath(pageData.path);
+      }).then(function(done) {
+        return PageGroupRelation.removeAllByPage(pageData);
       }).then(function(done) {
         pageEvent.emit('delete', pageData, user); // update as renamed page
         resolve(pageData);
@@ -1297,25 +1284,22 @@ module.exports = function(crowi) {
       , createRedirectPage = options.createRedirectPage || 0
       , moveUnderTrees     = options.moveUnderTrees || 0;
 
-    return new Promise(function(resolve, reject) {
-      // pageData の path を変更
-      Page.updatePageProperty(pageData, {updatedAt: Date.now(), path: newPagePath, lastUpdateUser: user})
-      .then(function(data) {
+    return Page.updatePageProperty(pageData, {updatedAt: Date.now(), path: newPagePath, lastUpdateUser: user})  // pageData の path を変更
+      .then((data) => {
         // reivisions の path を変更
         return Revision.updateRevisionListByPath(path, {path: newPagePath}, {});
-      }).then(function(data) {
+      })
+      .then(function(data) {
         pageData.path = newPagePath;
 
         if (createRedirectPage) {
           var body = 'redirect ' + newPagePath;
-          Page.create(path, body, user, {redirectTo: newPagePath}).then(resolve).catch(reject);
-        }
-        else {
-          resolve(data);
+          Page.create(path, body, user, {redirectTo: newPagePath});
         }
         pageEvent.emit('update', pageData, user); // update as renamed page
+
+        return pageData;
       });
-    });
   };
 
   pageSchema.statics.renameRecursively = function(pageData, newPagePathPrefix, user, options) {
@@ -1323,20 +1307,17 @@ module.exports = function(crowi) {
       , path = pageData.path
       , pathRegExp = new RegExp('^' + escapeStringRegexp(path), 'i');
 
-    return new Promise(function(resolve, reject) {
-      Page
-      .generateQueryToListWithDescendants(path, user, options)
+    return Page.generateQueryToListWithDescendants(path, user, options)
       .then(function(pages) {
-        Promise.all(pages.map(function(page) {
-          newPagePath = page.path.replace(pathRegExp, newPagePathPrefix);
+        return Promise.all(pages.map(function(page) {
+          const newPagePath = page.path.replace(pathRegExp, newPagePathPrefix);
           return Page.rename(page, newPagePath, user, options);
-        }))
-        .then(function() {
-          pageData.path = newPagePathPrefix;
-          return resolve();
-        });
+        }));
+      })
+      .then(function() {
+        pageData.path = newPagePathPrefix;
+        return pageData;
       });
-    });
   };
 
   pageSchema.statics.getHistories = function() {

+ 0 - 12
lib/models/user-group-relation.js

@@ -66,8 +66,6 @@ class UserGroupRelation {
    */
   static findAllRelationForUserGroup(userGroup) {
     debug('findAllRelationForUserGroup is called', userGroup);
-    var UserGroupRelation = this;
-
     return this
       .find({ relatedGroup: userGroup })
       .populate('relatedUser')
@@ -83,7 +81,6 @@ class UserGroupRelation {
    * @memberof UserGroupRelation
    */
   static findAllRelationForUserGroups(userGroups) {
-
     return this
       .find({ relatedGroup: { $in: userGroups } })
       .populate('relatedUser')
@@ -99,7 +96,6 @@ class UserGroupRelation {
    * @memberof UserGroupRelation
    */
   static findAllRelationForUser(user) {
-
     return this
       .find({ relatedUser: user.id })
       .populate('relatedGroup')
@@ -199,10 +195,6 @@ class UserGroupRelation {
       .then((count) => {
         // return true or false of the relation is exists(not count)
         return (0 < count);
-      })
-      .catch((err) => {
-        debug('An Error occured.', err);
-        reject(err);
       });
   }
 
@@ -263,10 +255,6 @@ class UserGroupRelation {
         else {
           relationData.remove();
         }
-      })
-      .catch((err) => {
-        debug('Error on find a removing user-group-relation', err);
-        reject(err);
       });
   }
 

+ 4 - 3
lib/routes/index.js

@@ -148,9 +148,10 @@ module.exports = function(crowi, app) {
   app.get( '/_search'                 , loginRequired(crowi, app, false) , search.searchPage);
   app.get( '/_api/search'             , accessTokenParser , loginRequired(crowi, app, false) , search.api.search);
 
-  app.get( '/_api/check_username'     , user.api.checkUsername);
-  app.post('/_api/me/picture/upload'  , loginRequired(crowi, app) , uploads.single('userPicture'), me.api.uploadPicture);
-  app.get( '/_api/user/bookmarks'     , loginRequired(crowi, app, false) , user.api.bookmarks);
+  app.get( '/_api/check_username'           , user.api.checkUsername);
+  app.post('/_api/me/picture/upload'        , loginRequired(crowi, app) , uploads.single('userPicture'), me.api.uploadPicture);
+  app.get( '/_api/me/user-group-relations'  , accessTokenParser , loginRequired(crowi, app) , me.api.userGroupRelations);
+  app.get( '/_api/user/bookmarks'           , loginRequired(crowi, app, false) , user.api.bookmarks);
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   app.get('/_api/users.list'          , accessTokenParser , loginRequired(crowi, app, false) , user.api.list);

+ 14 - 2
lib/routes/me.js

@@ -5,10 +5,10 @@ module.exports = function(crowi, app) {
     , fs = require('fs')
     , models = crowi.models
     , config = crowi.getConfig()
-    , Page = models.Page
     , User = models.User
+    , UserGroupRelation = models.UserGroupRelation
     , ExternalAccount = models.ExternalAccount
-    , Revision = models.Revision
+    , ApiResponse = require('../util/apiResponse')
     //, pluginService = require('../service/plugin')
     , actions = {}
     , api = {}
@@ -76,6 +76,18 @@ module.exports = function(crowi, app) {
     });
   };
 
+  /**
+   * retrieve user-group-relation documents
+   * @param {object} req
+   * @param {object} res
+   */
+  api.userGroupRelations = function(req, res) {
+    UserGroupRelation.findAllRelationForUser(req.user)
+      .then(userGroupRelations => {
+        return res.json(ApiResponse.success({userGroupRelations}));
+      });
+  };
+
   actions.index = function(req, res) {
     var userForm = req.body.userForm;
     var userData = req.user;

+ 65 - 60
lib/routes/page.js

@@ -181,11 +181,13 @@ module.exports = function(crowi, app) {
       else {
         return Promise.resolve([]);
       }
-    }).then(function(tree) {
+    })
+    .then(function(tree) {
       renderVars.tree = tree;
 
       return Page.findListByStartWith(path, req.user, queryOptions);
-    }).then(function(pageList) {
+    })
+    .then(function(pageList) {
 
       if (pageList.length > limit) {
         pageList.pop();
@@ -198,6 +200,16 @@ module.exports = function(crowi, app) {
       };
       renderVars.pager = generatePager(pagerOptions);
       renderVars.pages = pagePathUtil.encodePagesPath(pageList);
+    })
+    .then(() => {
+      return PageGroupRelation.findByPage(renderVars.page);
+    })
+    .then((pageGroupRelation) => {
+      if (pageGroupRelation != null) {
+        renderVars.pageRelatedGroup = pageGroupRelation.relatedGroup;
+      }
+    })
+    .then(() => {
       res.render('customlayout-selector/page_list', renderVars);
     }).catch(function(err) {
       debug('Error on rendering pageListShow', err);
@@ -238,7 +250,6 @@ module.exports = function(crowi, app) {
       author: false,
       pages: [],
       tree: [],
-      userRelatedGroups: [],
       pageRelatedGroup: null,
       template: null,
       localTemplateExists: false,
@@ -279,6 +290,14 @@ module.exports = function(crowi, app) {
           renderVars.localTemplateExists = templateInfo.localTemplateExists;
           renderVars.globalTemplateExists = templateInfo.globalTemplateExists;
         })
+        .then(() => {
+          return PageGroupRelation.findByPage(renderVars.page);
+        })
+        .then((pageGroupRelation) => {
+          if (pageGroupRelation != null) {
+            renderVars.pageRelatedGroup = pageGroupRelation.relatedGroup;
+          }
+        })
         .then(function() {
           var userPage = isUserPage(page.path);
           var userData = null;
@@ -308,14 +327,8 @@ module.exports = function(crowi, app) {
               // pass
             });
           }
-          else {
-            return Promise.resolve();
-          }
         });
       }
-      else {
-        return Promise.resolve();
-      }
     })
     // look for templates if page not exists
     .catch(function(err) {
@@ -330,47 +343,32 @@ module.exports = function(crowi, app) {
     .then(function() {
       if (!isRedirect) {
         Page.findListWithDescendants(path, req.user, queryOptions)
-        .then(function(pageList) {
-          if (pageList.length > limit) {
-            pageList.pop();
-          }
-
-          pagerOptions.length = pageList.length;
-
-          renderVars.viewConfig = {
-            seener_threshold: SEENER_THRESHOLD,
-          };
-          renderVars.pager = generatePager(pagerOptions);
-          renderVars.pages = pagePathUtil.encodePagesPath(pageList);
-
-          return Promise.resolve();
-        })
-        .then(function() {
-          return interceptorManager.process('beforeRenderPage', req, res, renderVars);
-        })
-        .then(function() {
-          res.render(req.query.presentation ? 'page_presentation' : pageTeamplate, renderVars);
-        })
-        .catch(function(err) {
-          console.log(err);
-          debug('Error on rendering pageListShowForCrowiPlus', err);
-        });
-      }
-    })
-    .then(function() {
-      return UserGroupRelation.findAllRelationForUser(req.user);
-    }).then(function(groupRelations) {
-      if (groupRelations != null) {
-        renderVars.userRelatedGroups = groupRelations.map(relation => relation.relatedGroup);
-      }
-
-      return PageGroupRelation.findByPage(renderVars.page);
-    }).then((pageGroupRelation) => {
-      if (pageGroupRelation != null) {
-        renderVars.pageRelatedGroup = pageGroupRelation.relatedGroup;
+          .then(function(pageList) {
+            if (pageList.length > limit) {
+              pageList.pop();
+            }
+
+            pagerOptions.length = pageList.length;
+
+            renderVars.viewConfig = {
+              seener_threshold: SEENER_THRESHOLD,
+            };
+            renderVars.pager = generatePager(pagerOptions);
+            renderVars.pages = pagePathUtil.encodePagesPath(pageList);
+
+            return;
+          })
+          .then(function() {
+            return interceptorManager.process('beforeRenderPage', req, res, renderVars);
+          })
+          .then(function() {
+            res.render(req.query.presentation ? 'page_presentation' : pageTeamplate, renderVars);
+          })
+          .catch(function(err) {
+            console.log(err);
+            debug('Error on rendering pageListShowForCrowiPlus', err);
+          });
       }
-
-      return Promise.resolve();
     });
   };
 
@@ -462,6 +460,10 @@ module.exports = function(crowi, app) {
             template: template,
           });
         });
+      return res.render('customlayout-selector/not_found', {
+        author: {},
+        page: false,
+      });
     }
 
     if (pageData.redirectTo) {
@@ -482,9 +484,16 @@ module.exports = function(crowi, app) {
     Revision.findRevisionList(pageData.path, {})
     .then(function(tree) {
       renderVars.tree = tree;
-
-      return Promise.resolve();
-    }).then(function() {
+    })
+    .then(() => {
+      return PageGroupRelation.findByPage(renderVars.page);
+    })
+    .then((pageGroupRelation) => {
+      if (pageGroupRelation != null) {
+        renderVars.pageRelatedGroup = pageGroupRelation.relatedGroup;
+      }
+    })
+    .then(function() {
       if (userPage) {
         return User.findUserByUsername(User.getUsernameByPath(pageData.path))
         .then(function(data) {
@@ -534,7 +543,6 @@ module.exports = function(crowi, app) {
 
   actions.pageShow = function(req, res) {
     var path = path || getPathFromRequest(req);
-    var options = {};
 
     // FIXME: せっかく getPathFromRequest になってるのにここが生 params[0] だとダサイ
     var isMarkdown = req.params[0].match(/.+\.md$/) || false;
@@ -648,15 +656,15 @@ module.exports = function(crowi, app) {
 
       if (data) {
         previousRevision = data.revision;
-        return Page.updatePage(data, body, req.user, { grant: grant, grantUserGroupId: grantUserGroupId});
+        return Page.updatePage(data, body, req.user, { grant, grantUserGroupId });
       }
       else {
         // new page
         updateOrCreate = 'create';
-        return Page.create(path, body, req.user, { grant: grant, grantUserGroupId: grantUserGroupId});
+        return Page.create(path, body, req.user, { grant, grantUserGroupId });
       }
     }).then(function(data) {
-      // data is a saved page data.
+      // data is a saved page data with revision.
       pageData = data;
       if (!data) {
         throw new Error('Data not found');
@@ -668,10 +676,7 @@ module.exports = function(crowi, app) {
 
           if (crowi.slack) {
             notify.slack.channel.split(',').map(function(chan) {
-              crowi.slack.post(pageData, req.user, chan, updateOrCreate, previousRevision)
-                .catch((err) => {
-                  debug(err);
-                });
+              crowi.slack.post(pageData, req.user, chan, updateOrCreate, previousRevision);
             });
           }
         }

+ 5 - 44
lib/views/_form.html

@@ -46,54 +46,15 @@
       </span>
       {% endif %}
 
-      {% if forceGrant %}
-      <input type="hidden" name="pageForm[grant]" value="{{ forceGrant }}">
-      {% else %}
-      <div>
-        <div id="page-grant-selector"></div>
-      </div>
-      <input type="hidden" id="page-grant" name="pageForm[grant]" value="{{ pageForm.grant|default(page.grant) }}">
-      <input id="grant-group" type="hidden" value="{% if pageForm.grant %}{{ pageForm.grant }}{% endif %}">
-<!--
-      <select id="select-grant" name="pageForm[grant]" class="m-r-5 selectpicker btn-group-sm">
-        {% for grantId, grantLabel in consts.pageGrants %}
-        <option value="{{ grantId }}" {% if pageForm.grant|default(page.grant) == grantId %}selected{% endif %}>{{ t(grantLabel) }}</option>
-        {% endfor %}
-        {% if user and user.admin && userRelatedGroups %}
-        <option id="no-group" value="/admin/user-groups">{{ t('Only inside the group') }} you have no groups.</option>
-        {% endif %}
-        <option id="group-grant" value="5">{{ t('Only inside the group') }}</option>
-        {% if pageForm.grant|default(page.grant) == "5" && pageRelatedGroup != null %}
-        <option id="group-grant" value="5" selected>{{pageRelatedGroup}}</option>
-        {% endif %}
-      </select>
-      <input id="select-grant-pre" type="hidden" value="{{ page.grant }}">
-      {% endif %}
-      <input id="grant-group" type="hidden" name="pageForm[grantUserGroupId]" value="">
-      {% if userRelatedGroups.length != 0 %}
-      <div class="collapse width">
-        <select name="pageForm[grantUserGroupId]" class="selectpicker btn-group-sm">
-          {% for userGroup in userRelatedGroups %}
-          <option value="{{ userGroup.id }}">{{ userGroup.name }}</option>
-          {% endfor %}
-        </select>
-      </div>
-      {% endif %} -->
-      {% if userRelatedGroups.length != 0 %}
-      <div>
-        <select name="pageForm[grantUserGroupId]" class="selectpicker btn-group-sm">
-          {% for userGroup in userRelatedGroups %}
-          <option value="{{ userGroup.id }}">{{ userGroup.name }}</option>
-          {% endfor %}
-        </select>
-      </div>
-      {% endif %}
-      <!-- <input type="hidden" id="page-grant" value="{{ page.grant }}"> -->
-      <input type="hidden" id="user-related-group-data" value="{{userRelatedGroups}}">
+      <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>
 </form>
+<input type="hidden" id="grant-group-name" value="{{ pageRelatedGroup.name }}">{# for storing group name #}
 <div class="file-module hidden">
 </div>

+ 2 - 0
lib/views/admin/customize.html

@@ -73,6 +73,8 @@
             {# Dark Themes #}
             <div class="d-flex">
               {% include 'widget/theme-colorbox.html' with { name: 'default-dark', bg: '#212731', topbar: '#151515', theme: '#f75b36' } %}
+              {# {% include 'widget/theme-colorbox.html' with { name: 'future', bg: '#16282D', topbar: '#011414', theme: '#04B4AE' } %} #}
+              {% include 'widget/theme-colorbox.html' with { name: 'blue-night', bg: '#061F2F', topbar: '#27343B', theme: '#0090C8' } %}
             </div>
           </div>
 

+ 1 - 0
lib/views/admin/widget/theme-colorbox.html

@@ -4,6 +4,7 @@
     data-theme="{{ webpack_asset('style-theme-' + name).css }}">
 
   <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
+    <title>{{name}}</title>
     <g>
       <path d="M -1 -1 L65 -1 L65 65 L-1 65 L-1 -1 Z" fill="{{bg}}"></path>
       <path d="M -1 -1 L65 -1 L65 15 L-1 15 L-1 -1 Z" fill="{{topbar}}"></path>

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

@@ -25,6 +25,5 @@
   </div>
 
   <div id="crowi-modals">
-    {% include '../modal/select_grant_group.html' %}
   </div>
 {% endblock %}

+ 2 - 0
lib/views/layout/layout.html

@@ -118,10 +118,12 @@ gh/highlightjs/cdn-release@9.12.0/build/languages/yaml.min.js
   class="main-container content-wrapper {% block html_base_css %}{% endblock %}
       {% if !layoutType() || 'crowi' === layoutType() %}crowi{% else %}growi{% endif %}"
   data-me="{{ user._id.toString() }}"
+  data-is-admin="{{ user.admin }}"
   data-plugin-enabled="{{ isEnabledPlugins() }}"
   {% block html_base_attr %}{% endblock %}
   data-csrftoken="{{ csrf() }}"
   data-current-username="{% if user %}{{ user.username }}{% endif %}"
+  data-userlang="{% if user %}{{ user.lang }}{% endif %}"
  >
 
 <div id="wrapper">

+ 0 - 23
lib/views/modal/select_grant_group.html

@@ -1,23 +0,0 @@
-<div class="modal select-grant-group" id="select-grant-group">
-  <div class="modal-dialog">
-    <div class="modal-content">
-
-      <div class="modal-header bg-primary">
-        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-        <div class="modal-title">{{ t('SelectGrantGroup') }}</div>
-      </div>
-
-      <div class="modal-body">
-        <p>グループを下のリストから選択</p>
-
-        <ul class="list-inline">
-          {% for sGroup in userRelatedGroups %}
-          <li><button class="btn btn-xs btn-primary" onclick="$('#grant-group').val('{{sGroup.id}}')" data-dismiss="modal">{{sGroup.name}}</button></li>
-          {% endfor %}
-        </ul>
-
-      </div><!-- /.modal-body -->
-
-    </div><!-- /.modal-content -->
-  </div><!-- /.modal-dialog -->
-</div><!-- /.modal -->

+ 9 - 3
lib/views/widget/page_alerts.html

@@ -1,9 +1,15 @@
 <div class="row row-alerts">
   <div class="col-xs-12">
     {% if page && page.grant != 1 %}
-    <p class="alert alert-inverse alert-grant">
-      <i class="icon-fw icon-lock"></i><strong>{{ consts.pageGrants[page.grant] }}</strong> ({{ t('Browsing of this page is restricted') }})
-    </p>
+      <p class="alert alert-inverse alert-grant">
+      {% if page.grant == 2 %}
+        <i class="icon-fw icon-link"></i><strong>{{ consts.pageGrants[page.grant] }}</strong> ({{ t('Browsing of this page is restricted') }})
+      {% elseif page.grant == 4 %}
+        <i class="icon-fw icon-lock"></i><strong>{{ consts.pageGrants[page.grant] }}</strong> ({{ t('Browsing of this page is restricted') }})
+      {% elseif page.grant == 5 %}
+        <i class="icon-fw icon-organization"></i><strong>'{{ pageRelatedGroup.name }}' only</strong> ({{ t('Browsing of this page is restricted') }})
+      {% endif %}
+      </p>
     {% endif %}
 
     {% if page.isDeleted() %}

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

@@ -39,7 +39,7 @@
 
     {% if !page.isPublic() %}
     <span>
-      <i class="fa fa-lock"></i>
+      <i class="icon icon-lock"></i>
     </span>
     {% endif %}
   </span>

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

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

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

@@ -50,9 +50,17 @@
     {% endif %}
   {% endif %}
 
-  <li class="nav-main-right-tab pull-right"><a href="#revision-history" data-toggle="tab"><i class="icon-layers"></i> {{ t('History') }}</a></li>
+  <li class="nav-main-right-tab pull-right">
+    <a href="#revision-history" data-toggle="tab">
+      <i class="icon-layers"></i><span class="hidden-xs"> {{ t('History') }}</span>
+    </a>
+  </li>
   {% if not isPortal %}
-    <li class="nav-main-right-tab pull-right"><a href="?presentation=1" class="toggle-presentation"><i class="icon-film"></i> {{ t('Presentation Mode') }}</a></li>
+    <li class="nav-main-right-tab pull-right">
+      <a href="?presentation=1" class="toggle-presentation">
+        <i class="icon-film"></i><span class="hidden-xs"> {{ t('Presentation Mode') }}</span>
+      </a>
+    </li>
   {% endif %}
 
 </ul>

+ 8 - 4
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.1.0-RC",
+  "version": "3.1.2-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -39,6 +39,7 @@
     "prebuild:prod": "npm run plugin:def",
     "prestart": "npm run build:prod",
     "postserver:prod:container": "echo ---------------------------------------- && echo [WARNING] && echo   'server:prod:container' is deprecated. && echo   Please use 'sever:prod' && echo ----------------------------------------",
+    "server:debug": "env-cmd config/env.dev.js node-dev --inspect app.js",
     "server:dev": "env-cmd config/env.dev.js node-dev --respawn app.js",
     "server:prod:container": "npm run server:prod",
     "server:prod:ci": "npm run server:prod -- --ci",
@@ -97,16 +98,18 @@
     "passport-local": "^1.0.0",
     "react": "^16.2.0",
     "react-dom": "^16.2.0",
+    "react-i18next": "^7.6.1",
     "rimraf": "^2.6.1",
     "slack-node": "^0.1.8",
     "socket.io": "^2.0.3",
     "string-width": "^2.1.1",
     "swig-templates": "^2.0.2",
     "uglifycss": "^0.0.29",
-    "xss": "^0.3.5"
+    "xss": "^1.0.3"
   },
   "devDependencies": {
-    "assets-webpack-plugin": "~3.5.1",
+    "@alienfast/i18next-loader": "^1.0.16",
+    "assets-webpack-plugin": "^3.6.0",
     "autoprefixer": "^8.2.0",
     "babel-core": "^6.25.0",
     "babel-loader": "^7.1.1",
@@ -134,6 +137,7 @@
     "eslint-plugin-react": "^7.7.0",
     "extract-text-webpack-plugin": "^3.0.2",
     "file-loader": "^1.1.0",
+    "i18next-browser-languagedetector": "^2.2.0",
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
@@ -159,7 +163,7 @@
     "postcss-loader": "^2.1.3",
     "react-bootstrap": "^0.32.1",
     "react-bootstrap-typeahead": "^3.0.3",
-    "react-clipboard.js": "^1.1.3",
+    "react-clipboard.js": "^2.0.0",
     "react-codemirror2": "^5.0.0",
     "react-dropzone": "^4.2.7",
     "reveal.js": "^3.5.0",

+ 30 - 27
resource/js/app.js

@@ -1,5 +1,8 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
+import { I18nextProvider } from 'react-i18next';
+
+import i18nFactory from './i18n';
 
 import Crowi from './util/Crowi';
 // import CrowiRenderer from './util/CrowiRenderer';
@@ -10,7 +13,7 @@ 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, { UserGroup, PageGrant } from './components/PageEditor/GrantSelector';
+import GrantSelector    from './components/PageEditor/GrantSelector';
 import Page             from './components/Page';
 import PageListSearch   from './components/PageListSearch';
 import PageHistory      from './components/PageHistory';
@@ -33,6 +36,9 @@ if (!window) {
   window = {};
 }
 
+const userlang = $('body').data('userlang');
+const i18n = i18nFactory(userlang);
+
 const mainContent = document.querySelector('#content-main');
 let pageId = null;
 let pageRevisionId = null;
@@ -57,6 +63,7 @@ const isLoggedin = document.querySelector('.main-container.nologin') == null;
 // FIXME
 const crowi = new Crowi({
   me: $('body').data('current-username'),
+  isAdmin: $('body').data('is-admin'),
   csrfToken: $('body').data('csrftoken'),
 }, window);
 window.crowi = crowi;
@@ -181,37 +188,33 @@ if (pageEditorOptionsSelectorElem) {
   );
 }
 // render GrantSelector
-const userRelatedGroupsElem = document.getElementById('user-related-group-data');
 const pageEditorGrantSelectorElem = document.getElementById('page-grant-selector');
-const pageGrantElem = document.getElementById('page-grant');
-const pageGrantGroupElem = document.getElementById('grant-group');
-function updatePageGrantElems(newPageGrant) {
-  pageGrantElem.value = newPageGrant.grant;
-  pageGrantGroupElem.value = newPageGrant.grantGroup.userGroupId || '';
-}
 if (pageEditorGrantSelectorElem) {
-  let userRelatedGroups;
-  if (userRelatedGroupsElem != null) {
-    let userRelatedGroupsJSONString = userRelatedGroupsElem.textContent;
-    if (userRelatedGroupsJSONString != null && userRelatedGroupsJSONString.length > 0) {
-      userRelatedGroups = JSON.parse(userRelatedGroupsJSONString || '{}', (value) => {
-        return new UserGroup(value);
-      });
-    }
+  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;
   }
-  pageGrant = new PageGrant();
-  pageGrant.grant = document.getElementById('page-grant').value;
-  const grantGroupData = JSON.parse(document.getElementById('grant-group').textContent || '{}');
-  if (grantGroupData != null) {
-    const grantGroup = new UserGroup();
-    grantGroup.userGroupId = grantGroupData.id;
-    grantGroup.userGroup = grantGroupData;
-    pageGrant.grantGroup = grantGroup;
+  function updateGrantGroupNameElem(grantGroupName) {
+    grantGroupNameElem.value = grantGroupName;
   }
+  /* eslint-enable */
+  const pageGrant = +grantElem.value;
+  const pageGrantGroupId = grantGroupElem.value;
+  const pageGrantGroupName = grantGroupNameElem.value;
   ReactDOM.render(
-    <GrantSelector crowi={crowi}
-      userRelatedGroups={userRelatedGroups} pageGrant={pageGrant}
-      onChange={updatePageGrantElems} />,
+    <I18nextProvider i18n={i18n}>
+      <GrantSelector crowi={crowi}
+        pageGrant={pageGrant} pageGrantGroupId={pageGrantGroupId} pageGrantGroupName={pageGrantGroupName}
+        onChangePageGrant={updateGrantElem}
+        onDeterminePageGrantGroupId={updateGrantGroupElem}
+        onDeterminePageGrantGroupName={updateGrantGroupNameElem} />
+    </I18nextProvider>,
     pageEditorGrantSelectorElem
   );
 }

+ 5 - 3
resource/js/components/Page.js

@@ -109,15 +109,17 @@ export default class Page extends React.Component {
 
   render() {
     const config = this.props.crowi.getConfig();
+    const isMobile = this.props.crowi.isMobile;
     const isMathJaxEnabled = !!config.env.MATHJAX;
 
-    return (
-      <RevisionBody html={this.state.html}
+    return <div className={isMobile ? 'page-mobile' : ''}>
+      <RevisionBody
+          html={this.state.html}
           inputRef={el => this.revisionBodyElement = el}
           isMathJaxEnabled={isMathJaxEnabled}
           renderMathJaxOnInit={true}
       />
-    );
+    </div>;
   }
 }
 

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

@@ -368,6 +368,7 @@ export default class PageEditor extends React.Component {
         <div className="col-md-6 col-sm-12 page-editor-editor-container">
           <Editor ref="editor" value={this.state.markdown}
             editorOptions={this.state.editorOptions}
+            isMobile={this.props.crowi.isMobile}
             isUploadable={this.state.isUploadable}
             isUploadableFile={this.state.isUploadableFile}
             emojiStrategy={emojiStrategy}

+ 104 - 0
resource/js/components/PageEditor/AbstractEditor.js

@@ -0,0 +1,104 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class AbstractEditor extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.forceToFocus = this.forceToFocus.bind(this);
+    this.setCaretLine = this.setCaretLine.bind(this);
+    this.setScrollTopByLine = this.setScrollTopByLine.bind(this);
+
+    this.getStrFromBol = this.getStrFromBol.bind(this);
+    this.getStrToEol = this.getStrToEol.bind(this);
+    this.insertText = this.insertText.bind(this);
+    this.insertLinebreak = this.insertLinebreak.bind(this);
+
+    this.dispatchSave = this.dispatchSave.bind(this);
+  }
+
+  forceToFocus() {
+  }
+
+  /**
+   * set caret position of codemirror
+   * @param {string} number
+   */
+  setCaretLine(line) {
+  }
+
+  /**
+   * scroll
+   * @param {number} line
+   */
+  setScrollTopByLine(line) {
+  }
+
+  /**
+   * return strings from BOL(beginning of line) to current position
+   */
+  getStrFromBol() {
+    throw new Error('this method should be impelemented in subclass');
+  }
+
+  /**
+   * return strings from current position to EOL(end of line)
+   */
+  getStrToEol() {
+    throw new Error('this method should be impelemented in subclass');
+  }
+
+  /**
+   * replace Beggining Of Line to current position with param 'text'
+   * @param {string} text
+   */
+  replaceBolToCurrentPos(text) {
+    throw new Error('this method should be impelemented in subclass');
+  }
+
+  /**
+   * insert text
+   * @param {string} text
+   */
+  insertText(text) {
+  }
+
+  /**
+   * insert line break to the current position
+   */
+  insertLinebreak() {
+    this.insertText('\n');
+  }
+
+  /**
+   * dispatch onSave event
+   */
+  dispatchSave() {
+    if (this.props.onSave != null) {
+      this.props.onSave();
+    }
+  }
+
+  /**
+   * dispatch onPasteFiles event
+   * @param {object} event
+   */
+  dispatchPasteFiles(event) {
+    if (this.props.onPasteFiles != null) {
+      this.props.onPasteFiles(event);
+    }
+  }
+}
+
+AbstractEditor.propTypes = {
+  value: PropTypes.string,
+  editorOptions: PropTypes.object,
+  onChange: PropTypes.func,
+  onScroll: PropTypes.func,
+  onScrollCursorIntoView: PropTypes.func,
+  onSave: PropTypes.func,
+  onPasteFiles: PropTypes.func,
+  onDragEnter: PropTypes.func,
+};
+

+ 438 - 0
resource/js/components/PageEditor/CodeMirrorEditor.js

@@ -0,0 +1,438 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import AbstractEditor from './AbstractEditor';
+
+import urljoin from 'url-join';
+const loadScript = require('simple-load-script');
+const loadCssSync = require('load-css-file');
+
+import * as codemirror from 'codemirror';
+
+import { UnControlled as ReactCodeMirror } from 'react-codemirror2';
+require('codemirror/addon/display/autorefresh');
+require('codemirror/addon/edit/matchbrackets');
+require('codemirror/addon/edit/matchtags');
+require('codemirror/addon/edit/closetag');
+require('codemirror/addon/edit/continuelist');
+require('codemirror/addon/hint/show-hint');
+require('codemirror/addon/hint/show-hint.css');
+require('codemirror/addon/search/searchcursor');
+require('codemirror/addon/search/match-highlighter');
+require('codemirror/addon/selection/active-line');
+require('codemirror/addon/scroll/annotatescrollbar');
+require('codemirror/addon/fold/foldcode');
+require('codemirror/addon/fold/foldgutter');
+require('codemirror/addon/fold/foldgutter.css');
+require('codemirror/addon/fold/markdown-fold');
+require('codemirror/addon/fold/brace-fold');
+require('codemirror/mode/gfm/gfm');
+
+import pasteHelper from './PasteHelper';
+import EmojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
+
+import InterceptorManager from '../../../../lib/util/interceptor-manager';
+
+import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
+import MarkdownTableInterceptor from './MarkdownTableInterceptor';
+import mtu from './MarkdownTableUtil';
+
+export default class CodeMirrorEditor extends AbstractEditor {
+
+  constructor(props) {
+    super(props);
+    this.logger = require('@alias/logger')('growi:PageEditor:CodeMirrorEditor');
+
+    this.state = {
+      value: this.props.value,
+      isEnabledEmojiAutoComplete: false,
+      isLoadingKeymap: false,
+      additionalClass: '',
+    };
+
+    this.init();
+
+    this.getCodeMirror = this.getCodeMirror.bind(this);
+
+    this.getBol = this.getBol.bind(this);
+    this.getEol = this.getEol.bind(this);
+
+    this.loadTheme = this.loadTheme.bind(this);
+    this.loadKeymapMode = this.loadKeymapMode.bind(this);
+    this.setKeymapMode = this.setKeymapMode.bind(this);
+    this.handleEnterKey = this.handleEnterKey.bind(this);
+
+    this.scrollCursorIntoViewHandler = this.scrollCursorIntoViewHandler.bind(this);
+    this.pasteHandler = this.pasteHandler.bind(this);
+    this.cursorHandler = this.cursorHandler.bind(this);
+
+    this.renderLoadingKeymapOverlay = this.renderLoadingKeymapOverlay.bind(this);
+  }
+
+  init() {
+    this.cmCdnRoot = 'https://cdn.jsdelivr.net/npm/codemirror@5.37.0';
+
+    this.interceptorManager = new InterceptorManager();
+    this.interceptorManager.addInterceptors([
+      new PreventMarkdownListInterceptor(),
+      new MarkdownTableInterceptor(),
+    ]);
+
+    this.loadedThemeSet = new Set(['eclipse', 'elegant']);   // themes imported in _vendor.scss
+    this.loadedKeymapSet = new Set();
+  }
+
+  componentWillMount() {
+    if (this.props.emojiStrategy != null) {
+      this.emojiAutoCompleteHelper = new EmojiAutoCompleteHelper(this.props.emojiStrategy);
+      this.setState({isEnabledEmojiAutoComplete: true});
+    }
+  }
+
+  componentDidMount() {
+    // initialize caret line
+    this.setCaretLine(0);
+    // set save handler
+    codemirror.commands.save = this.dispatchSave;
+
+    // set CodeMirror instance as 'CodeMirror' so that CDN addons can reference
+    window.CodeMirror = require('codemirror');
+  }
+
+  componentWillReceiveProps(nextProps) {
+    // load theme
+    const theme = nextProps.editorOptions.theme;
+    this.loadTheme(theme);
+
+    // set keymap
+    const keymapMode = nextProps.editorOptions.keymapMode;
+    this.setKeymapMode(keymapMode);
+  }
+
+  getCodeMirror() {
+    return this.refs.cm.editor;
+  }
+
+  /**
+   * @inheritDoc
+   */
+  forceToFocus() {
+    const editor = this.getCodeMirror();
+    // use setInterval with reluctance -- 2018.01.11 Yuki Takei
+    const intervalId = setInterval(() => {
+      this.getCodeMirror().focus();
+      if (editor.hasFocus()) {
+        clearInterval(intervalId);
+        // refresh
+        editor.refresh();
+      }
+    }, 100);
+  }
+
+  /**
+   * @inheritDoc
+   */
+  setCaretLine(line) {
+    if (isNaN(line)) {
+      return;
+    }
+
+    const editor = this.getCodeMirror();
+    const linePosition = Math.max(0, line);
+
+    editor.setCursor({line: linePosition});   // leave 'ch' field as null/undefined to indicate the end of line
+    this.setScrollTopByLine(linePosition);
+  }
+
+  /**
+   * @inheritDoc
+   */
+  setScrollTopByLine(line) {
+    if (isNaN(line)) {
+      return;
+    }
+
+    const editor = this.getCodeMirror();
+    // get top position of the line
+    var top = editor.charCoords({line, ch: 0}, 'local').top;
+    editor.scrollTo(null, top);
+  }
+
+  /**
+   * @inheritDoc
+   */
+  getStrFromBol() {
+    const editor = this.getCodeMirror();
+    const curPos = editor.getCursor();
+    return editor.getDoc().getRange(this.getBol(), curPos);
+  }
+
+  /**
+   * @inheritDoc
+   */
+  getStrToEol() {
+    const editor = this.getCodeMirror();
+    const curPos = editor.getCursor();
+    return editor.getDoc().getRange(curPos, this.getEol());
+  }
+
+  /**
+   * @inheritDoc
+   */
+  replaceBolToCurrentPos(text) {
+    const editor = this.getCodeMirror();
+    editor.getDoc().replaceRange(text, this.getBol(), editor.getCursor());
+  }
+
+  /**
+   * @inheritDoc
+   */
+  insertText(text) {
+    const editor = this.getCodeMirror();
+    editor.getDoc().replaceSelection(text);
+  }
+
+  /**
+   * return the postion of the BOL(beginning of line)
+   */
+  getBol() {
+    const editor = this.getCodeMirror();
+    const curPos = editor.getCursor();
+    return { line: curPos.line, ch: 0 };
+  }
+
+  /**
+   * return the postion of the EOL(end of line)
+   */
+  getEol() {
+    const editor = this.getCodeMirror();
+    const curPos = editor.getCursor();
+    const lineLength = editor.getDoc().getLine(curPos.line).length;
+    return { line: curPos.line, ch: lineLength };
+  }
+
+  loadCss(source) {
+    return new Promise((resolve) => {
+      loadCssSync(source);
+      resolve();
+    });
+  }
+
+  /**
+   * load Theme
+   * @see https://codemirror.net/doc/manual.html#config
+   *
+   * @param {string} theme
+   */
+  loadTheme(theme) {
+    if (!this.loadedThemeSet.has(theme)) {
+      this.loadCss(urljoin(this.cmCdnRoot, `theme/${theme}.min.css`));
+
+      // update Set
+      this.loadedThemeSet.add(theme);
+    }
+  }
+
+  /**
+   * load assets for Key Maps
+   * @param {*} keymapMode 'default' or 'vim' or 'emacs' or 'sublime'
+   */
+  loadKeymapMode(keymapMode) {
+    const loadCss = this.loadCss;
+    let scriptList = [];
+    let cssList = [];
+
+    // add dependencies
+    if (this.loadedKeymapSet.size == 0) {
+      scriptList.push(loadScript(urljoin(this.cmCdnRoot, 'addon/dialog/dialog.min.js')));
+      cssList.push(loadCss(urljoin(this.cmCdnRoot, 'addon/dialog/dialog.min.css')));
+    }
+    // load keymap
+    if (!this.loadedKeymapSet.has(keymapMode)) {
+      scriptList.push(loadScript(urljoin(this.cmCdnRoot, `keymap/${keymapMode}.min.js`)));
+      // update Set
+      this.loadedKeymapSet.add(keymapMode);
+    }
+
+    // set loading state
+    this.setState({ isLoadingKeymap: true });
+
+    return Promise.all(scriptList.concat(cssList))
+      .then(() => {
+        this.setState({ isLoadingKeymap: false });
+      });
+  }
+
+  /**
+   * set Key Maps
+   * @see https://codemirror.net/doc/manual.html#keymaps
+   *
+   * @param {string} keymapMode 'default' or 'vim' or 'emacs' or 'sublime'
+   */
+  setKeymapMode(keymapMode) {
+    if (!keymapMode.match(/^(vim|emacs|sublime)$/)) {
+      // reset
+      this.getCodeMirror().setOption('keyMap', 'default');
+      return;
+    }
+
+    this.loadKeymapMode(keymapMode)
+      .then(() => {
+        this.getCodeMirror().setOption('keyMap', keymapMode);
+      });
+  }
+
+  /**
+   * handle ENTER key
+   */
+  handleEnterKey() {
+    var context = {
+      handlers: [],  // list of handlers which process enter key
+      editor: this,
+    };
+
+    const interceptorManager = this.interceptorManager;
+    interceptorManager.process('preHandleEnter', context)
+      .then(() => {
+        if (context.handlers.length == 0) {
+          codemirror.commands.newlineAndIndentContinueMarkdownList(this.getCodeMirror());
+        }
+      });
+  }
+
+  scrollCursorIntoViewHandler(editor, event) {
+    if (this.props.onScrollCursorIntoView != null) {
+      const line = editor.getCursor().line;
+      this.props.onScrollCursorIntoView(line);
+    }
+  }
+
+  cursorHandler(editor, event) {
+    const strFromBol = this.getStrFromBol();
+    if (mtu.isEndOfLine(editor) && mtu.linePartOfTableRE.test(strFromBol)) {
+      this.setState({additionalClass: 'autoformat-markdown-table-activated'});
+    }
+    else {
+      this.setState({additionalClass: ''});
+    }
+  }
+
+  /**
+   * CodeMirror paste event handler
+   * see: https://codemirror.net/doc/manual.html#events
+   * @param {any} editor An editor instance of CodeMirror
+   * @param {any} event
+   */
+  pasteHandler(editor, event) {
+    const types = event.clipboardData.types;
+
+    // text
+    if (types.includes('text/plain')) {
+      pasteHelper.pasteText(this, event);
+    }
+    // files
+    else if (types.includes('Files')) {
+      this.dispatchPasteFiles(event);
+    }
+  }
+
+  getOverlayStyle() {
+    return {
+      position: 'absolute',
+      zIndex: 4,  // forward than .CodeMirror-gutters
+      top: 0,
+      right: 0,
+      bottom: 0,
+      left: 0,
+    };
+  }
+
+  renderLoadingKeymapOverlay() {
+    const overlayStyle = this.getOverlayStyle();
+
+    return this.state.isLoadingKeymap
+      ? <div style={overlayStyle} className="loading-keymap overlay">
+          <span className="overlay-content">
+            <div className="speeding-wheel d-inline-block"></div> Loading Keymap ...
+          </span>
+        </div>
+      : '';
+  }
+
+  render() {
+    const theme = this.props.editorOptions.theme || 'elegant';
+    const styleActiveLine = this.props.editorOptions.styleActiveLine || undefined;
+    return <React.Fragment>
+      <ReactCodeMirror
+        ref="cm"
+        className={this.state.additionalClass}
+        editorDidMount={(editor) => {
+          // add event handlers
+          editor.on('paste', this.pasteHandler);
+          editor.on('scrollCursorIntoView', this.scrollCursorIntoViewHandler);
+        }}
+        value={this.state.value}
+        options={{
+          mode: 'gfm',
+          theme: theme,
+          styleActiveLine: styleActiveLine,
+          lineNumbers: true,
+          tabSize: 4,
+          indentUnit: 4,
+          lineWrapping: true,
+          autoRefresh: true,
+          autoCloseTags: true,
+          matchBrackets: true,
+          matchTags: {bothTags: true},
+          // folding
+          foldGutter: true,
+          gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
+          // match-highlighter, matchesonscrollbar, annotatescrollbar options
+          highlightSelectionMatches: {annotateScrollbar: true},
+          // markdown mode options
+          highlightFormatting: true,
+          // continuelist, indentlist
+          extraKeys: {
+            'Enter': this.handleEnterKey,
+            'Tab': 'indentMore',
+            'Shift-Tab': 'indentLess',
+            'Ctrl-Q': (cm) => { cm.foldCode(cm.getCursor()) },
+          }
+        }}
+        onCursor={this.cursorHandler}
+        onScroll={(editor, data) => {
+          if (this.props.onScroll != null) {
+            // add line data
+            const line = editor.lineAtHeight(data.top, 'local');
+            data.line = line;
+            this.props.onScroll(data);
+          }
+        }}
+        onChange={(editor, data, value) => {
+          if (this.props.onChange != null) {
+            this.props.onChange(value);
+          }
+
+          // Emoji AutoComplete
+          if (this.state.isEnabledEmojiAutoComplete) {
+            this.emojiAutoCompleteHelper.showHint(editor);
+          }
+        }}
+        onDragEnter={(editor, event) => {
+          if (this.props.onDragEnter != null) {
+            this.props.onDragEnter(event);
+          }
+        }}
+      />
+
+      { this.renderLoadingKeymapOverlay() }
+
+    </React.Fragment>;
+  }
+
+}
+
+CodeMirrorEditor.propTypes = Object.assign({
+  emojiStrategy: PropTypes.object,
+}, AbstractEditor.propTypes);
+

+ 76 - 349
resource/js/components/PageEditor/Editor.js

@@ -1,234 +1,74 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import urljoin from 'url-join';
-const loadScript = require('simple-load-script');
-const loadCssSync = require('load-css-file');
-
-import * as codemirror from 'codemirror';
-
-import { UnControlled as ReactCodeMirror } from 'react-codemirror2';
-require('codemirror/addon/display/autorefresh');
-require('codemirror/addon/edit/matchbrackets');
-require('codemirror/addon/edit/matchtags');
-require('codemirror/addon/edit/closetag');
-require('codemirror/addon/edit/continuelist');
-require('codemirror/addon/hint/show-hint');
-require('codemirror/addon/hint/show-hint.css');
-require('codemirror/addon/search/searchcursor');
-require('codemirror/addon/search/match-highlighter');
-require('codemirror/addon/selection/active-line');
-require('codemirror/addon/scroll/annotatescrollbar');
-require('codemirror/addon/fold/foldcode');
-require('codemirror/addon/fold/foldgutter');
-require('codemirror/addon/fold/foldgutter.css');
-require('codemirror/addon/fold/markdown-fold');
-require('codemirror/addon/fold/brace-fold');
-require('codemirror/mode/gfm/gfm');
-
+import AbstractEditor from './AbstractEditor';
+import CodeMirrorEditor from './CodeMirrorEditor';
+import TextAreaEditor from './TextAreaEditor';
 
 import Dropzone from 'react-dropzone';
 
 import pasteHelper from './PasteHelper';
-import EmojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
-
-import InterceptorManager from '../../../../lib/util/interceptor-manager';
-
-import MarkdownListInterceptor from './MarkdownListInterceptor';
-import MarkdownTableInterceptor from './MarkdownTableInterceptor';
 
-export default class Editor extends React.Component {
+export default class Editor extends AbstractEditor {
 
   constructor(props) {
     super(props);
 
-    this.cmCdnRoot = 'https://cdn.jsdelivr.net/npm/codemirror@5.37.0';
-
-    this.interceptorManager = new InterceptorManager();
-    this.interceptorManager.addInterceptors([
-      new MarkdownListInterceptor(),
-      new MarkdownTableInterceptor(),
-    ]);
-
     this.state = {
-      value: this.props.value,
       dropzoneActive: false,
-      isEnabledEmojiAutoComplete: false,
       isUploading: false,
-      isLoadingKeymap: false,
     };
 
-    this.loadedThemeSet = new Set(['eclipse', 'elegant']);   // themes imported in _vendor.scss
-    this.loadedKeymapSet = new Set();
-
-    this.getCodeMirror = this.getCodeMirror.bind(this);
-    this.setCaretLine = this.setCaretLine.bind(this);
-    this.setScrollTopByLine = this.setScrollTopByLine.bind(this);
-    this.loadTheme = this.loadTheme.bind(this);
-    this.loadKeymapMode = this.loadKeymapMode.bind(this);
-    this.setKeymapMode = this.setKeymapMode.bind(this);
-    this.forceToFocus = this.forceToFocus.bind(this);
-    this.dispatchSave = this.dispatchSave.bind(this);
-    this.handleEnterKey = this.handleEnterKey.bind(this);
+    this.getEditorSubstance = this.getEditorSubstance.bind(this);
 
-    this.onScrollCursorIntoView = this.onScrollCursorIntoView.bind(this);
-    this.onPaste = this.onPaste.bind(this);
+    this.pasteFilesHandler = this.pasteFilesHandler.bind(this);
 
-    this.onDragEnterForCM = this.onDragEnterForCM.bind(this);
-    this.onDragLeave = this.onDragLeave.bind(this);
-    this.onDrop = this.onDrop.bind(this);
+    this.dragEnterHandler = this.dragEnterHandler.bind(this);
+    this.dragLeaveHandler = this.dragLeaveHandler.bind(this);
+    this.dropHandler = this.dropHandler.bind(this);
 
     this.getDropzoneAccept = this.getDropzoneAccept.bind(this);
     this.getDropzoneClassName = this.getDropzoneClassName.bind(this);
     this.renderDropzoneOverlay = this.renderDropzoneOverlay.bind(this);
-
-    this.renderLoadingKeymapOverlay = this.renderLoadingKeymapOverlay.bind(this);
-  }
-
-  componentWillMount() {
-    if (this.props.emojiStrategy != null) {
-      this.emojiAutoCompleteHelper = new EmojiAutoCompleteHelper(this.props.emojiStrategy);
-      this.setState({isEnabledEmojiAutoComplete: true});
-    }
   }
 
   componentDidMount() {
     // initialize caret line
     this.setCaretLine(0);
-    // set save handler
-    codemirror.commands.save = this.dispatchSave;
-
-    // set CodeMirror instance as 'CodeMirror' so that CDN addons can reference
-    window.CodeMirror = require('codemirror');
   }
 
-  componentWillReceiveProps(nextProps) {
-    // load theme
-    const theme = nextProps.editorOptions.theme;
-    this.loadTheme(theme);
-
-    // set keymap
-    const keymapMode = nextProps.editorOptions.keymapMode;
-    this.setKeymapMode(keymapMode);
-  }
-
-  getCodeMirror() {
-    return this.refs.cm.editor;
-  }
-
-  loadCss(source) {
-    return new Promise((resolve) => {
-      loadCssSync(source);
-      resolve();
-    });
+  getEditorSubstance() {
+    return this.props.isMobile
+      ? this.refs.taEditor
+      : this.refs.cmEditor;
   }
 
+  /**
+   * @inheritDoc
+   */
   forceToFocus() {
-    const editor = this.getCodeMirror();
-    // use setInterval with reluctance -- 2018.01.11 Yuki Takei
-    const intervalId = setInterval(() => {
-      this.getCodeMirror().focus();
-      if (editor.hasFocus()) {
-        clearInterval(intervalId);
-        // refresh
-        editor.refresh();
-      }
-    }, 100);
+    this.getEditorSubstance().forceToFocus();
   }
 
   /**
-   * set caret position of codemirror
-   * @param {string} number
+   * @inheritDoc
    */
   setCaretLine(line) {
-    if (isNaN(line)) {
-      return;
-    }
-
-    const editor = this.getCodeMirror();
-    const linePosition = Math.max(0, line);
-
-    editor.setCursor({line: linePosition});   // leave 'ch' field as null/undefined to indicate the end of line
-    this.setScrollTopByLine(linePosition);
+    this.getEditorSubstance().setCaretLine(line);
   }
 
   /**
-   * scroll
-   * @param {number} line
+   * @inheritDoc
    */
   setScrollTopByLine(line) {
-    if (isNaN(line)) {
-      return;
-    }
-
-    const editor = this.getCodeMirror();
-    // get top position of the line
-    var top = editor.charCoords({line, ch: 0}, 'local').top;
-    editor.scrollTo(null, top);
-  }
-
-  /**
-   * load Theme
-   * @see https://codemirror.net/doc/manual.html#config
-   *
-   * @param {string} theme
-   */
-  loadTheme(theme) {
-    if (!this.loadedThemeSet.has(theme)) {
-      this.loadCss(urljoin(this.cmCdnRoot, `theme/${theme}.min.css`));
-
-      // update Set
-      this.loadedThemeSet.add(theme);
-    }
-  }
-
-  /**
-   * load assets for Key Maps
-   * @param {*} keymapMode 'default' or 'vim' or 'emacs' or 'sublime'
-   */
-  loadKeymapMode(keymapMode) {
-    const loadCss = this.loadCss;
-    let scriptList = [];
-    let cssList = [];
-
-    // add dependencies
-    if (this.loadedKeymapSet.size == 0) {
-      scriptList.push(loadScript(urljoin(this.cmCdnRoot, 'addon/dialog/dialog.min.js')));
-      cssList.push(loadCss(urljoin(this.cmCdnRoot, 'addon/dialog/dialog.min.css')));
-    }
-    // load keymap
-    if (!this.loadedKeymapSet.has(keymapMode)) {
-      scriptList.push(loadScript(urljoin(this.cmCdnRoot, `keymap/${keymapMode}.min.js`)));
-      // update Set
-      this.loadedKeymapSet.add(keymapMode);
-    }
-
-    // set loading state
-    this.setState({ isLoadingKeymap: true });
-
-    return Promise.all(scriptList.concat(cssList))
-      .then(() => {
-        this.setState({ isLoadingKeymap: false });
-      });
+    this.getEditorSubstance().setScrollTopByLine(line);
   }
 
   /**
-   * set Key Maps
-   * @see https://codemirror.net/doc/manual.html#keymaps
-   *
-   * @param {string} keymapMode 'default' or 'vim' or 'emacs' or 'sublime'
+   * @inheritDoc
    */
-  setKeymapMode(keymapMode) {
-    if (!keymapMode.match(/^(vim|emacs|sublime)$/)) {
-      // reset
-      this.getCodeMirror().setOption('keyMap', 'default');
-      return;
-    }
-
-    this.loadKeymapMode(keymapMode)
-      .then(() => {
-        this.getCodeMirror().setOption('keyMap', keymapMode);
-      });
+  insertText(text) {
+    this.getEditorSubstance().insertText(text);
   }
 
   /**
@@ -241,24 +81,6 @@ export default class Editor extends React.Component {
     });
   }
 
-  /**
-   * insert text
-   * @param {string} text
-   */
-  insertText(text) {
-    const editor = this.getCodeMirror();
-    editor.getDoc().replaceSelection(text);
-  }
-
-  /**
-   * dispatch onSave event
-   */
-  dispatchSave() {
-    if (this.props.onSave != null) {
-      this.props.onSave();
-    }
-  }
-
   /**
    * dispatch onUpload event
    */
@@ -268,68 +90,26 @@ export default class Editor extends React.Component {
     }
   }
 
-  /**
-   * handle ENTER key
-   */
-  handleEnterKey() {
-
-    const editor = this.getCodeMirror();
-    var context = {
-      handlers: [],  // list of handlers which process enter key
-      editor: editor,
-    };
-
-    const interceptorManager = this.interceptorManager;
-    interceptorManager.process('preHandleEnter', context)
-      .then(() => {
-        if (context.handlers.length == 0) {
-          codemirror.commands.newlineAndIndentContinueMarkdownList(editor);
-        }
-      });
-  }
-
-  onScrollCursorIntoView(editor, event) {
-    if (this.props.onScrollCursorIntoView != null) {
-      const line = editor.getCursor().line;
-      this.props.onScrollCursorIntoView(line);
-    }
-  }
-
-  /**
-   * CodeMirror paste event handler
-   * see: https://codemirror.net/doc/manual.html#events
-   * @param {any} editor An editor instance of CodeMirror
-   * @param {any} event
-   */
-  onPaste(editor, event) {
-    const types = event.clipboardData.types;
+  pasteFilesHandler(event) {
+    const dropzone = this.refs.dropzone;
+    const items = event.clipboardData.items || event.clipboardData.files || [];
 
-    // text
-    if (types.includes('text/plain')) {
-      pasteHelper.pasteText(editor, event);
+    // abort if length is not 1
+    if (items.length != 1) {
+      return;
     }
-    // files
-    else if (types.includes('Files')) {
-      const dropzone = this.refs.dropzone;
-      const items = event.clipboardData.items || event.clipboardData.files || [];
-
-      // abort if length is not 1
-      if (items.length != 1) {
-        return;
-      }
 
-      const file = items[0].getAsFile();
-      // check type and size
-      if (pasteHelper.fileAccepted(file, dropzone.props.accept) &&
-          pasteHelper.fileMatchSize(file, dropzone.props.maxSize, dropzone.props.minSize)) {
+    const file = items[0].getAsFile();
+    // check type and size
+    if (pasteHelper.fileAccepted(file, dropzone.props.accept) &&
+        pasteHelper.fileMatchSize(file, dropzone.props.maxSize, dropzone.props.minSize)) {
 
-        this.dispatchUpload(file);
-        this.setState({ isUploading: true });
-      }
+      this.dispatchUpload(file);
+      this.setState({ isUploading: true });
     }
   }
 
-  onDragEnterForCM(editor, event) {
+  dragEnterHandler(event) {
     const dataTransfer = event.dataTransfer;
 
     // do nothing if contents is not files
@@ -340,11 +120,11 @@ export default class Editor extends React.Component {
     this.setState({ dropzoneActive: true });
   }
 
-  onDragLeave() {
+  dragLeaveHandler() {
     this.setState({ dropzoneActive: false });
   }
 
-  onDrop(accepted, rejected) {
+  dropHandler(accepted, rejected) {
     // rejected
     if (accepted.length != 1) { // length should be 0 or 1 because `multiple={false}` is set
       this.setState({ dropzoneActive: false });
@@ -418,18 +198,6 @@ export default class Editor extends React.Component {
     );
   }
 
-  renderLoadingKeymapOverlay() {
-    const overlayStyle = this.getOverlayStyle();
-
-    return this.state.isLoadingKeymap
-      ? <div style={overlayStyle} className="loading-keymap overlay">
-          <span className="overlay-content">
-            <div className="speeding-wheel d-inline-block"></div> Loading Keymap ...
-          </span>
-        </div>
-      : '';
-  }
-
   render() {
     const flexContainer = {
       height: '100%',
@@ -437,79 +205,45 @@ export default class Editor extends React.Component {
       flexDirection: 'column',
     };
 
-    const theme = this.props.editorOptions.theme || 'elegant';
-    const styleActiveLine = this.props.editorOptions.styleActiveLine || undefined;
+    const isMobile = this.props.isMobile;
+
     return <React.Fragment>
       <div style={flexContainer}>
         <Dropzone
-          ref="dropzone"
-          disableClick
-          disablePreview={true}
-          accept={this.getDropzoneAccept()}
-          className={this.getDropzoneClassName()}
-          acceptClassName="dropzone-accepted"
-          rejectClassName="dropzone-rejected"
-          multiple={false}
-          onDragLeave={this.onDragLeave}
-          onDrop={this.onDrop}
-        >
+            ref="dropzone"
+            disableClick
+            disablePreview={true}
+            accept={this.getDropzoneAccept()}
+            className={this.getDropzoneClassName()}
+            acceptClassName="dropzone-accepted"
+            rejectClassName="dropzone-rejected"
+            multiple={false}
+            onDragLeave={this.dragLeaveHandler}
+            onDrop={this.dropHandler}
+          >
+
           { this.state.dropzoneActive && this.renderDropzoneOverlay() }
 
-          <ReactCodeMirror
-            ref="cm"
-            editorDidMount={(editor) => {
-              // add event handlers
-              editor.on('paste', this.onPaste);
-              editor.on('scrollCursorIntoView', this.onScrollCursorIntoView);
-            }}
-            value={this.state.value}
-            options={{
-              mode: 'gfm',
-              theme: theme,
-              styleActiveLine: styleActiveLine,
-              lineNumbers: true,
-              tabSize: 4,
-              indentUnit: 4,
-              lineWrapping: true,
-              autoRefresh: true,
-              autoCloseTags: true,
-              matchBrackets: true,
-              matchTags: {bothTags: true},
-              // folding
-              foldGutter: true,
-              gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
-              // match-highlighter, matchesonscrollbar, annotatescrollbar options
-              highlightSelectionMatches: {annotateScrollbar: true},
-              // markdown mode options
-              highlightFormatting: true,
-              // continuelist, indentlist
-              extraKeys: {
-                'Enter': this.handleEnterKey,
-                'Tab': 'indentMore',
-                'Shift-Tab': 'indentLess',
-                'Ctrl-Q': (cm) => { cm.foldCode(cm.getCursor()) },
-              }
-            }}
-            onScroll={(editor, data) => {
-              if (this.props.onScroll != null) {
-                // add line data
-                const line = editor.lineAtHeight(data.top, 'local');
-                data.line = line;
-                this.props.onScroll(data);
-              }
-            }}
-            onChange={(editor, data, value) => {
-              if (this.props.onChange != null) {
-                this.props.onChange(value);
-              }
-
-              // Emoji AutoComplete
-              if (this.state.isEnabledEmojiAutoComplete) {
-                this.emojiAutoCompleteHelper.showHint(editor);
-              }
-            }}
-            onDragEnter={this.onDragEnterForCM}
-          />
+          {/* for PC */}
+          { !isMobile &&
+            <CodeMirrorEditor
+              ref="cmEditor"
+              onPasteFiles={this.pasteFilesHandler}
+              onDragEnter={this.dragEnterHandler}
+              {...this.props}
+            />
+          }
+
+          {/* for mobile */}
+          { isMobile &&
+            <TextAreaEditor
+              ref="taEditor"
+              onPasteFiles={this.pasteFilesHandler}
+              onDragEnter={this.dragEnterHandler}
+              {...this.props}
+            />
+          }
+
         </Dropzone>
 
         <button type="button" className="btn btn-default btn-block btn-open-dropzone"
@@ -521,8 +255,6 @@ export default class Editor extends React.Component {
           or pasting from the clipboard.
         </button>
 
-        { this.renderLoadingKeymapOverlay() }
-
       </div>
 
     </React.Fragment>;
@@ -530,17 +262,12 @@ export default class Editor extends React.Component {
 
 }
 
-Editor.propTypes = {
-  value: PropTypes.string,
-  options: PropTypes.object,
-  editorOptions: PropTypes.object,
+Editor.propTypes = Object.assign({
+  isMobile: PropTypes.bool,
   isUploadable: PropTypes.bool,
   isUploadableFile: PropTypes.bool,
   emojiStrategy: PropTypes.object,
   onChange: PropTypes.func,
-  onScroll: PropTypes.func,
-  onScrollCursorIntoView: PropTypes.func,
-  onSave: PropTypes.func,
   onUpload: PropTypes.func,
-};
+}, AbstractEditor.propTypes);
 

+ 200 - 112
resource/js/components/PageEditor/GrantSelector.js

@@ -1,12 +1,14 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import { translate } from 'react-i18next';
 
 import FormGroup from 'react-bootstrap/es/FormGroup';
 import FormControl from 'react-bootstrap/es/FormControl';
-import ControlLabel from 'react-bootstrap/es/ControlLabel';
-// import Button from 'react-bootstrap/es/Button';
+import ListGroup from 'react-bootstrap/es/ListGroup';
+import ListGroupItem from 'react-bootstrap/es/ListGroupItem';
+import Modal from 'react-bootstrap/es/Modal';
 
-// import Modal from 'react-bootstrap/es/Modal';
+const SPECIFIED_GROUP_VALUE = 'specifiedGroup';
 
 /**
  * Page grant select component
@@ -15,79 +17,134 @@ import ControlLabel from 'react-bootstrap/es/ControlLabel';
  * @class GrantSelector
  * @extends {React.Component}
  */
-export default class GrantSelector extends React.Component {
+class GrantSelector extends React.Component {
 
   constructor(props) {
     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'
+    ];
+
     this.state = {
-      pageGrant: this.props.pageGrant,
-      isGroupModalShown: false,
+      pageGrant: this.props.pageGrant || 1,  // default: 1
+      userRelatedGroups: [],
+      isSelectGroupModalShown: false,
     };
+    if (this.props.pageGrantGroupId !== '') {
+      this.state.pageGrantGroup = {
+        _id: this.props.pageGrantGroupId,
+        name: this.props.pageGrantGroupName
+      };
+    }
 
-    this.availableGrants = [1, 2, /*3, */4, 5];
+    this.showSelectGroupModal = this.showSelectGroupModal.bind(this);
+    this.hideSelectGroupModal = this.hideSelectGroupModal.bind(this);
 
-    this.availableGrantLabels = {
-      1: 'Public',
-      2: 'Anyone with the linc',
-      // 3:'Specified users only',
-      4: 'Just me',
-      5: 'Only inside the group',
-    };
+    this.getGroupName = this.getGroupName.bind(this);
 
-    this.onChangeGrant = this.onChangeGrant.bind(this);
+    this.changeGrantHandler = this.changeGrantHandler.bind(this);
+    this.groupListItemClickHandler = this.groupListItemClickHandler.bind(this);
   }
 
-  // Init component when the component did mount.
-  componentDidMount() {
-    this.init();
+  componentDidUpdate(prevProps, prevState) {
+    /*
+     * set SPECIFIED_GROUP_VALUE to grant selector
+     *  cz: bootstrap-select input element has the defferent state to React component
+     */
+    if (this.state.pageGrantGroup != 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');
+    //// 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());
+
   }
 
-  // Initialize the component.
-  init() {
-    this.grantSelectorInputEl.value = this.state.pageGrant.grant;
+  showSelectGroupModal() {
+    this.retrieveUserGroupRelations();
+    this.setState({ isSelectGroupModalShown: true });
+  }
+  hideSelectGroupModal() {
+    this.setState({ isSelectGroupModalShown: false });
+  }
+
+  getGroupName() {
+    const pageGrantGroup = this.state.pageGrantGroup;
+    return pageGrantGroup ? pageGrantGroup.name : '';
   }
 
   /**
-   * On change event handler for pagegrant.
-   * @param {any} grant page grant
-   * @memberof GrantSelector
+   * Retrieve user-group-relations data from backend
+   */
+  retrieveUserGroupRelations() {
+    this.props.crowi.apiGet('/me/user-group-relations')
+      .then(res => {
+        return res.userGroupRelations;
+      })
+      .then(userGroupRelations => {
+        const userRelatedGroups = userGroupRelations.map(relation => {
+          return relation.relatedGroup;
+        });
+        this.setState({userRelatedGroups});
+      });
+  }
+
+  /**
+   * change event handler for pageGrant selector
    */
-  onChangeGrant(grant) {
-    const newValue = this.grantSelectorInputEl.value;
-    const newGrant = Object.assign(this.state.pageGrant, {grant: newValue});
-    this.setState({ pageGrant: newGrant });
+  changeGrantHandler() {
+    const pageGrant = +this.grantSelectorInputEl.value;
+
+    // select group
+    if (pageGrant === 5) {
+      this.showSelectGroupModal();
+      /*
+       * reset grant selector to state
+       */
+      this.grantSelectorInputEl.value = this.state.pageGrant;
+      return;
+    }
 
+    this.setState({ pageGrant, pageGrantGroup: null });
     // dispatch event
-    this.dispatchOnChange();
+    this.dispatchOnChangePageGrant(pageGrant);
+    this.dispatchOnDeterminePageGrantGroup(null);
   }
 
-  // (TBD)
-  // /**
-  //  * On click event handler for grant usergroup.
-  //  *
-  //  * @memberof GrantSelector
-  //  */
-  // onClickGrantGroup() {
-  //   const newValue = this.groupSelectorInputEl.value;
-  //   const newGrant = Object.assign(this.state.pageGrant, { grantGroup: newValue });
-  //   this.setState({ pageGrant: newGrant });
-
-  //   // dispatch event
-  //   this.dispatchOnChange();
-  //   // close group select modal
-  //   if (this.state.isModalShown) {
-  //     this.setState({ isGroupModalShown: false });
-  //   }
-  // }
+  groupListItemClickHandler(pageGrantGroup) {
+    this.setState({ pageGrant: 5, pageGrantGroup });
 
-  /**
-   * dispatch onChange event
-   * @memberof GrantSelector
-   */
-  dispatchOnChange() {
-    if (this.props.onChange != null) {
-      this.props.onChange(this.state.pageGrant);
+    // dispatch event
+    this.dispatchOnChangePageGrant(5);
+    this.dispatchOnDeterminePageGrantGroup(pageGrantGroup);
+
+    // 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 : '');
     }
   }
 
@@ -97,17 +154,48 @@ export default class GrantSelector extends React.Component {
    * @memberof GrantSelector
    */
   renderGrantSelector() {
+    const { t } = this.props;
+
+    let index = 0;
+    let selectedValue = this.state.pageGrant;
     const grantElems = this.availableGrants.map((grant) => {
-      return <option key={grant} value={grant}>{this.availableGrantLabels[grant]}</option>;
+      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>;
     });
 
-    const bsClassName = 'form-control-dummy'; // set form-control* to shrink width
+    const pageGrantGroup = this.state.pageGrantGroup;
+    if (pageGrantGroup != null) {
+      selectedValue = SPECIFIED_GROUP_VALUE;
+      // DIRTY HACK -- 2018.05.25 Yuki Takei
+      // remove 'Only inside the group' item
+      //  cz: .selectpicker('refresh') doesn't replace data-content
+      grantElems.splice(3, 1);
+    }
+    else {
+      // DIRTY HACK -- 2018.05.25 Yuki Takei
+      // remove 'Reselect the group' item
+      //  cz: .selectpicker('refresh') doesn't replace data-content
+      grantElems.splice(4, 1);
+    }
+
+    /*
+     * react-bootstrap couldn't be rendered only with React feature.
+     * see also 'componentDidUpdate'
+     */
 
+    // add specified group option
+    grantElems.push(
+      <option ref="specifiedGroupOption" key="specifiedGroupKey" value={SPECIFIED_GROUP_VALUE} style={{ display: pageGrantGroup ? '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>
+    );
+
+    const bsClassName = 'form-control-dummy'; // set form-control* to shrink width
     return (
-      <FormGroup controlId="formControlsSelect">
-        <ControlLabel>Grant:</ControlLabel>
-        <FormControl componentClass="select" placeholder="select" defaultValue={this.state.pageGrant.grant} bsClass={bsClassName} className="btn-group-sm selectpicker"
-          onChange={this.onChangeGrant}
+      <FormGroup className="m-b-0">
+        <FormControl componentClass="select" placeholder="select" defaultValue={selectedValue} bsClass={bsClassName} className="btn-group-sm page-grant-selector selectpicker"
+          onChange={this.changeGrantHandler}
           inputRef={ el => this.grantSelectorInputEl=el }>
 
           {grantElems}
@@ -117,66 +205,66 @@ export default class GrantSelector extends React.Component {
     );
   }
 
-  // (TBD)
-  // /**
-  //  * Render select grantgroup modal.
-  //  *
-  //  * @returns
-  //  * @memberof GrantSelector
-  //  */
-  // renderSelectGroupModal() {
-  //   // const userRelatedGroups = this.props.userRelatedGroups;
-  //   const groupList = this.userRelatedGroups.map((group) => {
-  //     return <li>
-  //         <Button onClick={this.onClickGrantGroup(group)} bsClass="btn btn-sm btn-primary">{group.name}</Button>
-  //       </li>;
-  //   });
-  //   return (
-  //     <Modal show={this.props.isGroupModalShown} className="select-grant-group">
-  //       <Modal.Header closeButton>
-  //         <Modal.Title>
-  //           Select a Group
-  //         </Modal.Title>
-  //       </Modal.Header>
-  //       <Modal.Body>
-
-  //         <ul className="list-inline">
-  //           {groupList}
-  //         </ul>
-  //       </Modal.Body>
-  //     </Modal>
-  //   );
-  // }
-
-  render() {
-    return <span>
-      <span className="m-l-5">{this.renderGrantSelector()}</span>
-    </span>;
-  }
-}
+  /**
+   * Render select grantgroup modal.
+   *
+   * @returns
+   * @memberof GrantSelector
+   */
+  renderSelectGroupModal() {
+    const generateGroupListItems = () => {
+      return this.state.userRelatedGroups.map((group) => {
+        return <ListGroupItem key={group._id} header={group.name} onClick={() => { this.groupListItemClickHandler(group) }}>
+            (TBD) List group members
+          </ListGroupItem>;
+      });
+    };
 
-export class PageGrant {
-  constructor(props) {
-    this.grant = '';
-    this.grantGroup = null;
+    let content = this.state.userRelatedGroups.length === 0
+      ? <div>
+          <h4>There is no group to which you belong.</h4>
+          { this.props.crowi.isAdmin &&
+            <p><a href="/admin/user-groups"><i className="icon icon-fw icon-login"></i> Manage Groups</a></p>
+          }
+        </div>
+      : <ListGroup>
+        {generateGroupListItems()}
+      </ListGroup>;
 
-    Object.assign(this, props);
+    return (
+        <Modal className="select-grant-group"
+          container={this} show={this.state.isSelectGroupModalShown} onHide={this.hideSelectGroupModal}
+        >
+          <Modal.Header closeButton>
+            <Modal.Title>
+              Select a Group
+            </Modal.Title>
+          </Modal.Header>
+          <Modal.Body>
+            {content}
+          </Modal.Body>
+        </Modal>
+    );
   }
-}
-
-export class UserGroup {
-  constructor(props) {
-    this.userGroupId = '';
-    this.userGroup;
 
-    Object.assign(this, props);
+  render() {
+    return <React.Fragment>
+      <div className="m-r-5">{this.renderGrantSelector()}</div>
+      {this.renderSelectGroupModal()}
+    </React.Fragment>;
   }
 }
 
 GrantSelector.propTypes = {
+  t: PropTypes.func.isRequired,               // i18next
   crowi: PropTypes.object.isRequired,
   isGroupModalShown: PropTypes.bool,
-  userRelatedGroups: PropTypes.object,
-  pageGrant: PropTypes.instanceOf(PageGrant),
-  onChange: PropTypes.func,
+  pageGrant: PropTypes.number,
+  pageGrantGroupId: PropTypes.string,
+  pageGrantGroupName: PropTypes.string,
+  onChangePageGrant: PropTypes.func,
+  onDeterminePageGrantGroupId: PropTypes.func,
+  onDeterminePageGrantGroupName: PropTypes.func,
 };
+
+export default translate()(GrantSelector);

+ 24 - 53
resource/js/components/PageEditor/MarkdownListUtil.js

@@ -1,5 +1,3 @@
-import * as codemirror from 'codemirror';
-
 /**
  * Utility for markdown list
  */
@@ -11,26 +9,41 @@ class MarkdownListUtil {
     this.indentAndMarkRE = /^(\s*)(>[> ]*|[*+-] \[[x ]\]\s|[*+-]\s|(\d+)([.)]))(\s*)/;
     this.indentAndMarkOnlyRE = /^(\s*)(>[> ]*|[*+-] \[[x ]\]|[*+-]|(\d+)[.)])(\s*)$/;
 
+    this.newlineAndIndentContinueMarkdownList = this.newlineAndIndentContinueMarkdownList.bind(this);
     this.pasteText = this.pasteText.bind(this);
+  }
+
+  /**
+   * Self Implementation with AbstractEditor interface
+   * @param {AbstractEditor} editor An instance of AbstractEditor
+   */
+  newlineAndIndentContinueMarkdownList(editor) {
+    const strFromBol = editor.getStrFromBol();
 
-    this.getBol = this.getBol.bind(this);
-    this.getEol = this.getEol.bind(this);
-    this.getStrFromBol = this.getStrFromBol.bind(this);
-    this.getStrToEol = this.getStrToEol.bind(this);
-    this.newlineWithoutIndent = this.newlineWithoutIndent.bind(this);
+    if (this.indentAndMarkOnlyRE.test(strFromBol)) {
+      // clear current line and end list
+      editor.replaceBolToCurrentPos('\n');
+    }
+    else if (this.indentAndMarkRE.test(strFromBol)) {
+      // continue list
+      const indentAndMark = strFromBol.match(this.indentAndMarkRE)[0];
+      editor.insertText(`\n${indentAndMark}`);
+    }
+    else {
+      editor.insertLinebreak();
+    }
   }
 
   /**
    * paste text
-   * @param {any} editor An editor instance of CodeMirror
+   * @param {AbstractEditor} editor An instance of AbstractEditor
    * @param {any} event
    * @param {string} text
    */
   pasteText(editor, event, text) {
     // get strings from BOL(beginning of line) to current position
-    const strFromBol = this.getStrFromBol(editor);
+    const strFromBol = editor.getStrFromBol();
 
-    const matched = strFromBol.match(this.indentAndMarkRE);
     // when match indentAndMarkOnlyRE
     // (this means the current position is the beginning of the list item)
     if (this.indentAndMarkOnlyRE.test(strFromBol)) {
@@ -39,7 +52,7 @@ class MarkdownListUtil {
       // replace
       if (adjusted != null) {
         event.preventDefault();
-        editor.getDoc().replaceRange(adjusted, this.getBol(editor), editor.getCursor());
+        editor.replaceBolToCurrentPos(adjusted);
       }
     }
   }
@@ -112,48 +125,6 @@ class MarkdownListUtil {
     return isListful;
   }
 
-  /**
-   * return the postion of the BOL(beginning of line)
-   */
-  getBol(editor) {
-    const curPos = editor.getCursor();
-    return { line: curPos.line, ch: 0 };
-  }
-
-  /**
-   * return the postion of the EOL(end of line)
-   */
-  getEol(editor) {
-    const curPos = editor.getCursor();
-    const lineLength = editor.getDoc().getLine(curPos.line).length;
-    return { line: curPos.line, ch: lineLength };
-  }
-
-  /**
-   * return strings from BOL(beginning of line) to current position
-   */
-  getStrFromBol(editor) {
-    const curPos = editor.getCursor();
-    return editor.getDoc().getRange(this.getBol(editor), curPos);
-  }
-
-  /**
-   * return strings from current position to EOL(end of line)
-   */
-  getStrToEol(editor) {
-    const curPos = editor.getCursor();
-    return editor.getDoc().getRange(curPos, this.getEol(editor));
-  }
-
-  /**
-   * insert newline without indent
-   */
-  newlineWithoutIndent(editor, strToEol) {
-    codemirror.commands.newlineAndIndent(editor);
-
-    // replace the line with strToEol (abort auto indent)
-    editor.getDoc().replaceRange(strToEol, this.getBol(editor), this.getEol(editor));
-  }
 }
 
 // singleton pattern

+ 13 - 6
resource/js/components/PageEditor/MarkdownTableInterceptor.js

@@ -32,25 +32,32 @@ export default class MarkdownTableInterceptor extends BasicInterceptor {
    */
   process(contextName, ...args) {
     const context = Object.assign(args[0]);   // clone
-    const editor = context.editor;
+    const editor = context.editor;            // AbstractEditor instance
+
+    // do nothing if editor is not a CodeMirrorEditor
+    if (editor == null || editor.getCodeMirror() == null) {
+      return Promise.resolve(context);
+    }
+
+    const cm = editor.getCodeMirror();
 
     // get strings from BOL(beginning of line) to current position
-    const strFromBol = mtu.getStrFromBol(editor);
+    const strFromBol = editor.getStrFromBol();
 
-    if (mtu.isEndOfLine(editor) && mtu.linePartOfTableRE.test(strFromBol)) {
+    if (mtu.isEndOfLine(cm) && mtu.linePartOfTableRE.test(strFromBol)) {
       // get lines all of table from current position to beginning of table
-      const strFromBot = mtu.getStrFromBot(editor);
+      const strFromBot = mtu.getStrFromBot(cm);
       let table = mtu.parseFromTableStringToMarkdownTable(strFromBot);
 
       mtu.addRowToMarkdownTable(table);
 
-      const strToEot = mtu.getStrToEot(editor);
+      const strToEot = mtu.getStrToEot(cm);
       const tableBottom = mtu.parseFromTableStringToMarkdownTable(strToEot);
       if (tableBottom.table.length > 0) {
         table = mtu.mergeMarkdownTable([table, tableBottom]);
       }
 
-      mtu.replaceMarkdownTableWithReformed(editor, table);
+      mtu.replaceMarkdownTableWithReformed(cm, table);
 
       // report to manager that handling was done
       context.handlers.push(this.className);

+ 1 - 10
resource/js/components/PageEditor/MarkdownTableUtil.js

@@ -11,14 +11,13 @@ class MarkdownTableUtil {
     // https://regex101.com/r/7BN2fR/7
     this.tableAlignmentLineRE = /^[-:|][-:|\s]*$/;
     this.tableAlignmentLineNegRE = /^[^-:]*$/;  // it is need to check to ignore empty row which is matched above RE
-    this.linePartOfTableRE = /^\|[^\r\n]*|[^\r\n]*\|$|([^\|\r\n]+\|[^\|\r\n]*)+/; // own idea
+    this.linePartOfTableRE = /^\|[^\r\n]*|[^\r\n]*\|$|([^|\r\n]+\|[^|\r\n]*)+/; // own idea
 
     this.getBot = this.getBot.bind(this);
     this.getEot = this.getEot.bind(this);
     this.getBol = this.getBol.bind(this);
     this.getStrFromBot = this.getStrFromBot.bind(this);
     this.getStrToEot = this.getStrToEot.bind(this);
-    this.getStrFromBol = this.getStrFromBol.bind(this);
 
     this.parseFromTableStringToMarkdownTable = this.parseFromTableStringToMarkdownTable.bind(this);
     this.replaceMarkdownTableWithReformed = this.replaceMarkdownTableWithReformed.bind(this);
@@ -85,14 +84,6 @@ class MarkdownTableUtil {
     return editor.getDoc().getRange(curPos, this.getEot(editor));
   }
 
-  /**
-   * return strings from BOL(beginning of line) to current position
-   */
-  getStrFromBol(editor) {
-    const curPos = editor.getCursor();
-    return editor.getDoc().getRange(this.getBol(editor), curPos);
-  }
-
   /**
    * returns markdown table whose described by 'markdown-table' format
    *   ref. https://github.com/wooorm/markdown-table

+ 5 - 4
resource/js/components/PageEditor/MarkdownListInterceptor.js → resource/js/components/PageEditor/PreventMarkdownListInterceptor.js

@@ -1,7 +1,7 @@
 import { BasicInterceptor } from 'growi-pluginkit';
 import mlu from './MarkdownListUtil';
 
-export default class MarkdownListInterceptor extends BasicInterceptor {
+export default class PreventMarkdownListInterceptor extends BasicInterceptor {
 
   constructor() {
     super();
@@ -28,12 +28,13 @@ export default class MarkdownListInterceptor extends BasicInterceptor {
    */
   process(contextName, ...args) {
     const context = Object.assign(args[0]);   // clone
-    const editor = context.editor;
+    const editor = context.editor;            // AbstractEditor instance
 
     // get strings from current position to EOL(end of line) before break the line
-    const strToEol = mlu.getStrToEol(editor);
+    const strToEol = editor.getStrToEol();
     if (mlu.indentAndMarkRE.test(strToEol)) {
-      mlu.newlineWithoutIndent(editor, strToEol);
+      // newline simply
+      editor.insertLinebreak();
 
       // report to manager that handling was done
       context.handlers.push(this.className);

+ 224 - 0
resource/js/components/PageEditor/TextAreaEditor.js

@@ -0,0 +1,224 @@
+import React from 'react';
+// import PropTypes from 'prop-types';
+
+import FormControl from 'react-bootstrap/es/FormControl';
+
+import AbstractEditor from './AbstractEditor';
+
+import pasteHelper from './PasteHelper';
+import mlu from './MarkdownListUtil';
+
+import InterceptorManager from '../../../../lib/util/interceptor-manager';
+
+import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
+
+export default class TextAreaEditor extends AbstractEditor {
+
+  constructor(props) {
+    super(props);
+    this.logger = require('@alias/logger')('growi:PageEditor:TextAreaEditor');
+
+    this.state = {
+      value: this.props.value,
+    };
+
+    this.init();
+
+    this.handleEnterKey = this.handleEnterKey.bind(this);
+
+    this.keyPressHandler = this.keyPressHandler.bind(this);
+    this.pasteHandler = this.pasteHandler.bind(this);
+    this.dragEnterHandler = this.dragEnterHandler.bind(this);
+  }
+
+  init() {
+    this.interceptorManager = new InterceptorManager();
+    this.interceptorManager.addInterceptors([
+      new PreventMarkdownListInterceptor(),
+    ]);
+  }
+
+  componentDidMount() {
+    // initialize caret line
+    this.setCaretLine(0);
+
+    // set event handlers
+    this.textarea.addEventListener('keypress', this.keyPressHandler);
+    this.textarea.addEventListener('paste', this.pasteHandler);
+    this.textarea.addEventListener('dragenter', this.dragEnterHandler);
+  }
+
+  /**
+   * @inheritDoc
+   */
+  forceToFocus() {
+    setTimeout(() => {
+      this.textarea.focus();
+    }, 150);
+  }
+
+  /**
+   * @inheritDoc
+   */
+  setCaretLine(line) {
+    if (isNaN(line)) {
+      return;
+    }
+
+    // scroll to bottom
+    this.textarea.scrollTop = this.textarea.scrollHeight;
+
+    const lines = this.textarea.value.split('\n').slice(0, line+1);
+    const pos = lines
+        .map(lineStr => lineStr.length + 1) // correct length+1 of each lines
+        .reduce((a, x) => a += x, 0)        // sum
+        - 1;                                // -1
+
+    this.textarea.setSelectionRange(pos, pos);
+  }
+
+  /**
+   * @inheritDoc
+   */
+  setScrollTopByLine(line) {
+    // do nothing
+  }
+
+  /**
+   * @inheritDoc
+   */
+  insertText(text) {
+    const startPos = this.textarea.selectionStart;
+    const endPos = this.textarea.selectionEnd;
+    this.replaceValue(text, startPos, endPos);
+  }
+
+  /**
+   * @inheritDoc
+   */
+  getStrFromBol() {
+    const currentPos = this.textarea.selectionStart;
+    return this.textarea.value.substring(this.getBolPos(), currentPos);
+  }
+
+  /**
+   * @inheritDoc
+   */
+  getStrToEol() {
+    const currentPos = this.textarea.selectionStart;
+    return this.textarea.value.substring(currentPos, this.getEolPos());
+  }
+
+  /**
+   * @inheritDoc
+   */
+  replaceBolToCurrentPos(text) {
+    const currentPos = this.textarea.selectionStart;
+    this.replaceValue(text, this.getBolPos(), currentPos);
+  }
+
+  getBolPos() {
+    const currentPos = this.textarea.selectionStart;
+    return this.textarea.value.lastIndexOf('\n', currentPos-1) + 1;
+  }
+
+  getEolPos() {
+    const currentPos = this.textarea.selectionStart;
+    const pos = this.textarea.value.indexOf('\n', currentPos);
+    if (pos < 0) {  // not found but EOF
+      return this.textarea.value.length;
+    }
+    return pos;
+  }
+
+  replaceValue(text, startPos, endPos) {
+    // create new value
+    const value = this.textarea.value;
+    const newValue = value.substring(0, startPos) + text + value.substring(endPos, value.length);
+    // calculate new position
+    const newPos = startPos + text.length;
+
+    this.textarea.value = newValue;
+    this.textarea.setSelectionRange(newPos, newPos);
+  }
+
+  /**
+   * keypress event handler
+   * @param {string} event
+   */
+  keyPressHandler(event) {
+    const key = event.key.toLowerCase();
+    if (key === 'enter') {
+      if (event.ctrlKey || event.altKey || event.metaKey) {
+        return;
+      }
+
+      event.preventDefault();
+      this.handleEnterKey();
+    }
+  }
+
+  /**
+   * handle ENTER key
+   */
+  handleEnterKey() {
+    var context = {
+      handlers: [],  // list of handlers which process enter key
+      editor: this,
+    };
+
+    const interceptorManager = this.interceptorManager;
+    interceptorManager.process('preHandleEnter', context)
+      .then(() => {
+        if (context.handlers.length == 0) {
+          mlu.newlineAndIndentContinueMarkdownList(this);
+        }
+      });
+  }
+
+  /**
+   * paste event handler
+   * @param {any} event
+   */
+  pasteHandler(event) {
+    const types = event.clipboardData.types;
+
+    // text
+    if (types.includes('text/plain')) {
+      pasteHelper.pasteText(this, event);
+    }
+    // files
+    else if (types.includes('Files')) {
+      this.dispatchPasteFiles(event);
+    }
+  }
+
+  dragEnterHandler(event) {
+    this.dispatchDragEnter(event);
+  }
+
+  dispatchDragEnter(event) {
+    if (this.props.onDragEnter != null) {
+      this.props.onDragEnter(event);
+    }
+  }
+
+  render() {
+    return <React.Fragment>
+      <FormControl
+        componentClass="textarea" className="textarea-editor"
+        inputRef={ref => { this.textarea = ref }}
+        defaultValue={this.state.value}
+        onChange={(e) => {
+          if (this.props.onChange != null) {
+            this.props.onChange(e.target.value);
+          }
+        }} />
+    </React.Fragment>;
+  }
+
+}
+
+TextAreaEditor.propTypes = Object.assign({
+}, AbstractEditor.propTypes);
+

+ 44 - 0
resource/js/i18n.js

@@ -0,0 +1,44 @@
+import i18n from 'i18next';
+import LanguageDetector from 'i18next-browser-languagedetector';
+import { reactI18nextModule } from 'react-i18next';
+
+import resources from '@alias/locales';
+
+export default (userlang) => {
+  // setup LanguageDetector
+  const langDetector = new LanguageDetector();
+  langDetector.addDetector({
+    name: 'userSettingDetector',
+    lookup(options) {
+      return userlang;
+    },
+    cacheUserlanguage(lng, options) {
+    },
+  });
+
+  return i18n
+    .use(langDetector)
+    .use(reactI18nextModule) // if not using I18nextProvider
+    .init({
+      debug: (process.env.NODE_ENV !== 'production'),
+      resources,
+      load: 'currentOnly',
+
+      fallbackLng: 'en-US',
+      detection: {
+        order: ['userSettingDetector', 'querystring', 'localStorage'],
+      },
+
+      interpolation: {
+        escapeValue: false, // not needed for react!!
+      },
+
+      // react i18next special options (optional)
+      react: {
+        wait: false,
+        bindI18n: 'languageChanged loaded',
+        bindStore: 'added removed',
+        nsMode: 'default'
+      }
+    });
+};

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

@@ -83,8 +83,8 @@ Crowi.setCaretLineAndFocusToEditor = function() {
 
   const line = pageEditorDom.getAttribute('data-caret-line');
 
-  if (line != null) {
-    crowi.setCaretLine(line);
+  if (line != null && !isNaN(line)) {
+    crowi.setCaretLine(+line);
     // reset data-caret-line attribute
     pageEditorDom.removeAttribute('data-caret-line');
   }

+ 4 - 1
resource/js/util/Crowi.js

@@ -18,6 +18,9 @@ export default class Crowi {
     this.config = {};
     this.csrfToken = context.csrfToken;
 
+    const userAgent = window.navigator.userAgent.toLowerCase();
+    this.isMobile = /iphone|ipad|android/.test(userAgent);
+
     this.window = window;
     this.location = window.location || {};
     this.document = window.document || {};
@@ -35,13 +38,13 @@ export default class Crowi {
 
     // FIXME
     this.me = context.me;
+    this.isAdmin = context.isAdmin;
 
     this.users = [];
     this.userByName = {};
     this.userById   = {};
     this.draft = {};
     this.editorOptions = {};
-    this.userRelatedGroups = {};
 
     this.recoverData();
   }

+ 54 - 0
resource/styles/agile-admin/inverse/colors/blue-night.scss

@@ -0,0 +1,54 @@
+@import '../variables';
+
+$basecolor:#061f2f;
+$themecolor:#0090c8;
+
+$linkcolor: #97d1f0;
+
+$topbar:#27343b;
+$sidebar:#061f2f;
+$bodycolor:#061f2f;
+$headingtext: #fff;
+$bodytext: #d3d4d4;
+$linktext: $linkcolor;
+$linktext-hover: rgba($linktext, 0.8);
+$sidebar-text:#d3d4d4;
+$dark-themecolor:#4f5467;
+
+$primary: $themecolor;
+
+$logo-mark-fill: lighten(desaturate($topbar, 10%), 15%);
+$wikilinktext: $linkcolor;
+$wikilinktext-hover: rgba($linktext, 0.8);
+
+$dark: darken($bodytext, 5%);
+$border: #fff;
+$navbar-border: lighten($basecolor, 25%);
+$active-navbar-border: darken($navbar-border, 3%);
+$btn-default-bgcolor: darken($basecolor, 10%);
+$inline-code-color: #c1f1f0;
+$inline-code-bg: #0a121b;
+
+@import 'apply-colors';
+@import 'apply-colors-dark';
+
+.wiki {
+  .highlighted {
+   background-color: lighten($themecolor, 20%);
+  }
+}
+
+.panel {
+  &, &.panel-white, &.panel-default {
+    border-color: $bodytext;
+    .panel-heading {
+      color: $basecolor;
+      background-color: 1px solid $bodytext;
+    }
+  }
+}
+
+:not(.hljs) > pre:not(.hljs) {
+  color: $bodytext;
+  background-color: $inline-code-bg;
+}

+ 36 - 0
resource/styles/agile-admin/inverse/colors/future.scss

@@ -0,0 +1,36 @@
+@import '../variables';
+
+$basecolor: #16282D;
+$themecolor:rgba(11, 79, 104, 0.616);
+
+$topbar:#011414;
+$sidebar:#fff;
+$bodycolor:$basecolor;
+$headingtext: #D9A364;
+$bodytext: lighten($basecolor, 35%);
+$linktext: lighten($basecolor, 45%);
+$linktext-hover: lighten($linktext, 80%);
+$sidebar-text:rgb(65, 133, 124);
+$dark-themecolor:#4F5467;
+
+
+$primary: $themecolor;
+$info: lighten($themecolor,20%);
+
+$logo-mark-fill: rgb(170, 245, 237);
+$wikilinktext: saturate($bodytext, 20%);
+$wikilinktext-hover: darken($wikilinktext, 5%);
+
+$dark: darken($bodytext, 5%);
+$border: lighten($basecolor, 15%);
+$navbar-border: lighten($border, 10%);
+$active-navbar-border: darken($border, 3%);
+$btn-default-bgcolor: darken($basecolor, 10%);
+$inline-code-bg: darken($bodycolor, 5%);
+
+@import 'apply-colors';
+@import 'apply-colors-dark';
+
+.bg-title{
+  border-bottom: 1px solid rgb(131, 228, 215);
+}

+ 30 - 15
resource/styles/scss/_on-edit.scss

@@ -29,15 +29,6 @@ body.on-edit {
     display: none;
   }
 
-  /*
-   * right tabs
-   */
-  .nav-main-right-tab:not(.dropdown) {
-    // hide if screen size is less than smartphone
-    @media (max-width: $screen-xs) {
-      display: none;
-    }
-  }
 
   /*****************
    * Expand Editor
@@ -91,20 +82,25 @@ body.on-edit {
         .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 {
-          height: calc(100vh - #{$editor-margin});
-          // less than smartphone
-          @media (max-width: $screen-xs) {
-            height: calc(100vh - #{$editor-margin-sm});
+
+          .react-codemirror2, .CodeMirror, .CodeMirror-scroll,
+          .textarea-editor {
+            height: calc(100vh - #{$editor-margin});
+            // less than smartphone
+            @media (max-width: $screen-xs) {
+              height: calc(100vh - #{$editor-margin-sm});
+            }
           }
         }
       }
-      }
+
 
       .page-editor-footer {
         width: 100%;
@@ -210,6 +206,13 @@ body.on-edit {
         color: #444;
       }
     }
+    // add icon on cursor
+    .autoformat-markdown-table-activated .CodeMirror-cursor {
+      &:after {
+        font-family: 'FontAwesome';
+        content: '\f0ce';
+      }
+    }
 
     // for Dropzone
     .dropzone {
@@ -282,6 +285,11 @@ body.on-edit {
       }
     } // end of.dropzone
 
+    .textarea-editor {
+      border: none;
+      font-family: monospace;
+    }
+
     .loading-keymap {
       @include overlay-processing-style();
     }
@@ -343,6 +351,13 @@ body.on-edit {
     }
   }
 
+  #page-grant-selector {
+    .btn-group {
+      min-width: 150px;
+    }
+  }
+
+
 } // }}}
 
 /*

+ 11 - 3
resource/styles/scss/_wiki.scss

@@ -134,17 +134,25 @@ div.body {
 
     .revision-head-link,
     .revision-head-edit-button {
-      visibility: hidden;
+      margin-left: 0.5em;
       font-size: 0.6em;
+      opacity: 0;
     }
     &:hover .revision-head-link,
     &:hover .revision-head-edit-button {
-      visibility: unset;
-      margin-left: 0.5em;
+      opacity: 1 !important;
     }
   }
 }
 
+// mobile
+.page-mobile .wiki .revision-head {
+  .revision-head-link,
+  .revision-head-edit-button {
+    opacity: 0.3;
+  }
+}
+
 @media (max-width: $screen-sm-max) { // {{{ tablet and iphone size
   .main-container .main .wiki {
     img {

+ 8 - 0
resource/styles/scss/theme/blue-night.scss

@@ -0,0 +1,8 @@
+// import colors
+@import '../../agile-admin/inverse/colors/blue-night';
+
+// apply agile-admin theme
+@import '../../agile-admin/inverse/style';
+
+// override
+@import 'override-agileadmin';

+ 8 - 0
resource/styles/scss/theme/future.scss

@@ -0,0 +1,8 @@
+// import colors
+@import '../../agile-admin/inverse/colors/future';
+
+// apply agile-admin theme
+@import '../../agile-admin/inverse/style';
+
+// override
+@import 'override-agileadmin';

+ 76 - 82
yarn.lock

@@ -2,6 +2,15 @@
 # yarn lockfile v1
 
 
+"@alienfast/i18next-loader@^1.0.16":
+  version "1.0.16"
+  resolved "https://registry.yarnpkg.com/@alienfast/i18next-loader/-/i18next-loader-1.0.16.tgz#218e9736c94457d36bdae68a5085ed68434c9bbf"
+  dependencies:
+    glob-all "^3.1.0"
+    js-yaml "^3.11.0"
+    loader-utils "^1.1.0"
+    lodash "^4.17.10"
+
 "@browser-bunyan/console-formatted-stream@^1.3.0":
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/@browser-bunyan/console-formatted-stream/-/console-formatted-stream-1.3.0.tgz#3dc059aa5c1b2a7a1f26e2706e2bdeb9a09bbe57"
@@ -348,14 +357,14 @@ assertion-error@^1.0.1:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
 
-assets-webpack-plugin@~3.5.1:
-  version "3.5.1"
-  resolved "https://registry.yarnpkg.com/assets-webpack-plugin/-/assets-webpack-plugin-3.5.1.tgz#931ce0d66d42e88ed5e7f18d65522943c57a387d"
+assets-webpack-plugin@^3.6.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/assets-webpack-plugin/-/assets-webpack-plugin-3.6.0.tgz#f0046def66acc728ef62ea9b8abc3716e1957adb"
   dependencies:
-    camelcase "^1.2.1"
+    camelcase "^5.0.0"
     escape-string-regexp "^1.0.3"
-    lodash.assign "^3.2.0"
-    lodash.merge "^3.3.2"
+    lodash.assign "^4.2.0"
+    lodash.merge "^4.6.1"
     mkdirp "^0.5.1"
 
 async-each-series@0.1.1:
@@ -1414,6 +1423,10 @@ camelcase@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
 
+camelcase@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.0.0.tgz#03295527d58bd3cd4aa75363f35b2e8d97be2f42"
+
 caniuse-api@^1.5.2:
   version "1.6.1"
   resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-1.6.1.tgz#b534e7c734c4f81ec5fbe8aca2ad24354b962c6c"
@@ -1588,9 +1601,9 @@ cli@~1.0.1:
     exit "0.1.2"
     glob "^7.1.1"
 
-clipboard@^1.6.1:
-  version "1.7.1"
-  resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-1.7.1.tgz#360d6d6946e99a7a1fef395e42ba92b5e9b5a16b"
+clipboard@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.1.tgz#a12481e1c13d8a50f5f036b0560fe5d16d74e46a"
   dependencies:
     good-listener "^1.2.2"
     select "^1.1.2"
@@ -3130,6 +3143,13 @@ getpass@^0.1.1:
   dependencies:
     assert-plus "^1.0.0"
 
+glob-all@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/glob-all/-/glob-all-3.1.0.tgz#8913ddfb5ee1ac7812656241b03d5217c64b02ab"
+  dependencies:
+    glob "^7.0.5"
+    yargs "~1.2.6"
+
 glob-base@^0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4"
@@ -3396,6 +3416,10 @@ hogan.js@^3.0.2:
     mkdirp "0.3.0"
     nopt "1.0.10"
 
+hoist-non-react-statics@^2.3.1:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.0.tgz#d2ca2dfc19c5a91c5a6615ce8e564ef0347e2a40"
+
 home-or-tmp@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8"
@@ -3411,6 +3435,12 @@ html-comment-regex@^1.1.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.1.tgz#668b93776eaae55ebde8f3ad464b307a4963625e"
 
+html-parse-stringify2@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/html-parse-stringify2/-/html-parse-stringify2-2.0.1.tgz#dc5670b7292ca158b7bc916c9a6735ac8872834a"
+  dependencies:
+    void-elements "^2.0.1"
+
 http-errors@1.6.2, http-errors@~1.6.1, http-errors@~1.6.2:
   version "1.6.2"
   resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz#0a002cc85707192a7e7946ceedc11155f60ec736"
@@ -3461,6 +3491,10 @@ humanize-ms@^1.2.1:
   dependencies:
     ms "^2.0.0"
 
+i18next-browser-languagedetector@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-2.2.0.tgz#5f41abe61964a56dce70102ab31c3ed5d5866edc"
+
 i18next-express-middleware@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/i18next-express-middleware/-/i18next-express-middleware-1.1.1.tgz#9204f28c8800ac3bff87fbee01945367956f349c"
@@ -3853,7 +3887,7 @@ js-yaml@3.5.4:
     argparse "^1.0.2"
     esprima "^2.6.0"
 
-js-yaml@^3.4.3, js-yaml@^3.9.1:
+js-yaml@^3.11.0, js-yaml@^3.4.3, js-yaml@^3.9.1:
   version "3.11.0"
   resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.11.0.tgz#597c1a8bd57152f26d622ce4117851a51f5ebaef"
   dependencies:
@@ -4162,30 +4196,10 @@ lodash._bindcallback@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz#e531c27644cf8b57a99e17ed95b35c748789392e"
 
-lodash._createassigner@^3.0.0:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/lodash._createassigner/-/lodash._createassigner-3.1.1.tgz#838a5bae2fdaca63ac22dee8e19fa4e6d6970b11"
-  dependencies:
-    lodash._bindcallback "^3.0.0"
-    lodash._isiterateecall "^3.0.0"
-    lodash.restparam "^3.0.0"
-
 lodash._getnative@^3.0.0:
   version "3.9.1"
   resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5"
 
-lodash._isiterateecall@^3.0.0:
-  version "3.0.9"
-  resolved "https://registry.yarnpkg.com/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz#5203ad7ba425fae842460e696db9cf3e6aac057c"
-
-lodash.assign@^3.2.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-3.2.0.tgz#3ce9f0234b4b2223e296b8fa0ac1fee8ebca64fa"
-  dependencies:
-    lodash._baseassign "^3.0.0"
-    lodash._createassigner "^3.0.0"
-    lodash.keys "^3.0.0"
-
 lodash.assign@^4.0.3, lodash.assign@^4.0.6, lodash.assign@^4.2.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7"
@@ -4229,22 +4243,10 @@ lodash.isfinite@^3.3.2:
   version "3.3.2"
   resolved "https://registry.yarnpkg.com/lodash.isfinite/-/lodash.isfinite-3.3.2.tgz#fb89b65a9a80281833f0b7478b3a5104f898ebb3"
 
-lodash.isplainobject@^3.0.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-3.2.0.tgz#9a8238ae16b200432960cd7346512d0123fbf4c5"
-  dependencies:
-    lodash._basefor "^3.0.0"
-    lodash.isarguments "^3.0.0"
-    lodash.keysin "^3.0.0"
-
 lodash.isstring@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
 
-lodash.istypedarray@^3.0.0:
-  version "3.0.6"
-  resolved "https://registry.yarnpkg.com/lodash.istypedarray/-/lodash.istypedarray-3.0.6.tgz#c9a477498607501d8e8494d283b87c39281cef62"
-
 lodash.keys@^3.0.0:
   version "3.1.2"
   resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a"
@@ -4253,41 +4255,18 @@ lodash.keys@^3.0.0:
     lodash.isarguments "^3.0.0"
     lodash.isarray "^3.0.0"
 
-lodash.keysin@^3.0.0:
-  version "3.0.8"
-  resolved "https://registry.yarnpkg.com/lodash.keysin/-/lodash.keysin-3.0.8.tgz#22c4493ebbedb1427962a54b445b2c8a767fb47f"
-  dependencies:
-    lodash.isarguments "^3.0.0"
-    lodash.isarray "^3.0.0"
-
 lodash.memoize@^4.1.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
 
-lodash.merge@^3.3.2:
-  version "3.3.2"
-  resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-3.3.2.tgz#0d90d93ed637b1878437bb3e21601260d7afe994"
-  dependencies:
-    lodash._arraycopy "^3.0.0"
-    lodash._arrayeach "^3.0.0"
-    lodash._createassigner "^3.0.0"
-    lodash._getnative "^3.0.0"
-    lodash.isarguments "^3.0.0"
-    lodash.isarray "^3.0.0"
-    lodash.isplainobject "^3.0.0"
-    lodash.istypedarray "^3.0.0"
-    lodash.keys "^3.0.0"
-    lodash.keysin "^3.0.0"
-    lodash.toplainobject "^3.0.0"
+lodash.merge@^4.6.1:
+  version "4.6.1"
+  resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.1.tgz#adc25d9cb99b9391c59624f379fbba60d7111d54"
 
 lodash.mergewith@^4.6.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.0.tgz#150cf0a16791f5903b8891eab154609274bdea55"
 
-lodash.restparam@^3.0.0:
-  version "3.6.1"
-  resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805"
-
 lodash.set@^4.3.2:
   version "4.3.2"
   resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23"
@@ -4300,13 +4279,6 @@ lodash.toarray@^4.4.0:
   version "4.4.0"
   resolved "https://registry.yarnpkg.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz#24c4bfcd6b2fba38bfd0594db1179d8e9b656561"
 
-lodash.toplainobject@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/lodash.toplainobject/-/lodash.toplainobject-3.0.0.tgz#28790ad942d293d78aa663a07ecf7f52ca04198d"
-  dependencies:
-    lodash._basecopy "^3.0.0"
-    lodash.keysin "^3.0.0"
-
 lodash.uniq@^4.5.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
@@ -4614,6 +4586,10 @@ minimist@0.0.8:
   version "0.0.8"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
 
+minimist@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.1.0.tgz#99df657a52574c21c9057497df742790b2b4c0de"
+
 minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
@@ -5952,11 +5928,11 @@ react-bootstrap@^0.32.1:
     uncontrollable "^4.1.0"
     warning "^3.0.0"
 
-react-clipboard.js@^1.1.3:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/react-clipboard.js/-/react-clipboard.js-1.1.3.tgz#86feeb49364553ecd15aea91c75aa142532a60e0"
+react-clipboard.js@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/react-clipboard.js/-/react-clipboard.js-2.0.0.tgz#8ab3c49093e73ea146eb3bbc054889b7a60bf2b4"
   dependencies:
-    clipboard "^1.6.1"
+    clipboard "^2.0.0"
     prop-types "^15.5.0"
 
 react-codemirror2@^5.0.0:
@@ -5979,6 +5955,14 @@ react-dropzone@^4.2.7:
     attr-accept "^1.0.3"
     prop-types "^15.5.7"
 
+react-i18next@^7.6.1:
+  version "7.6.1"
+  resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-7.6.1.tgz#c61d8284f3c695893d51033f67c39e65f01212b6"
+  dependencies:
+    hoist-non-react-statics "^2.3.1"
+    html-parse-stringify2 "2.0.1"
+    prop-types "^15.6.0"
+
 react-onclickoutside@^6.1.1:
   version "6.7.0"
   resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.7.0.tgz#997a4d533114c9a0a104913638aa26afc084f75c"
@@ -7423,6 +7407,10 @@ vm-browserify@0.0.4:
   dependencies:
     indexof "0.0.1"
 
+void-elements@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec"
+
 warning@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/warning/-/warning-3.0.0.tgz#32e5377cb572de4ab04753bdf8821c01ed605b7c"
@@ -7613,9 +7601,9 @@ xmlhttprequest-ssl@~1.5.4:
   version "1.5.4"
   resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.4.tgz#04f560915724b389088715cc0ed7813e9677bf57"
 
-xss@^0.3.5:
-  version "0.3.7"
-  resolved "https://registry.yarnpkg.com/xss/-/xss-0.3.7.tgz#1df6dc85c0240b455b5e5f0428bdeccd739ab4ee"
+xss@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/xss/-/xss-1.0.3.tgz#d04bd2558fd6c29c46113824d5e8b2a910054e23"
   dependencies:
     commander "^2.9.0"
     cssfilter "0.0.10"
@@ -7755,6 +7743,12 @@ yargs@^8.0.2:
     y18n "^3.2.1"
     yargs-parser "^7.0.0"
 
+yargs@~1.2.6:
+  version "1.2.6"
+  resolved "https://registry.yarnpkg.com/yargs/-/yargs-1.2.6.tgz#9c7b4a82fd5d595b2bf17ab6dcc43135432fe34b"
+  dependencies:
+    minimist "^0.1.0"
+
 yargs@~3.10.0:
   version "3.10.0"
   resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1"