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

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

WIP: Feat/integrate with hackmd
Yuki Takei 7 лет назад
Родитель
Сommit
03ed6e6379

+ 6 - 1
.babelrc

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

+ 2 - 1
.node-dev.json

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

+ 7 - 0
CHANGES.md

@@ -1,6 +1,13 @@
 CHANGES
 ========
 
+## 3.2.0-RC
+
+* Feature: Simultaneously edit by multiple people with HackMD integration
+* Support: Upgrade libs
+    * react
+    * react-dom
+
 ## 3.1.13-RC
 
 * 

+ 2 - 0
config/env.dev.js

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

+ 8 - 4
config/webpack.common.js

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

+ 15 - 0
config/webpack.dev.js

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

+ 1 - 1
config/webpack.prod.js

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

+ 1 - 0
lib/models/config.js

@@ -571,6 +571,7 @@ module.exports = function(crowi) {
       env: {
         PLANTUML_URI: env.PLANTUML_URI || null,
         BLOCKDIAG_URI: env.BLOCKDIAG_URI || null,
+        HACKMD_URI: env.HACKMD_URI || null,
         MATHJAX: env.MATHJAX || null,
       },
     };

+ 19 - 0
lib/models/page.js

@@ -69,6 +69,8 @@ module.exports = function(crowi) {
         return JSON.stringify(data);
       }
     },
+    pageIdOnHackmd: String,
+    revisionHackmdSynced: { type: ObjectId, ref: 'Revision' },
     createdAt: { type: Date, default: Date.now },
     updatedAt: Date
   }, {
@@ -1301,6 +1303,23 @@ module.exports = function(crowi) {
       });
   };
 
+  /**
+   * associate GROWI page and HackMD page
+   * @param {Page} pageData
+   * @param {string} pageIdOnHackmd
+   */
+  pageSchema.statics.registerHackmdPage = function(pageData, pageIdOnHackmd) {
+    if (pageData.pageIdOnHackmd != null) {
+      throw new Error(`'pageIdOnHackmd' of the page '${pageData.path}' is not empty`);
+    }
+
+    pageData.pageIdOnHackmd = pageIdOnHackmd;
+    pageData.revisionHackmdSynced = pageData.revision;
+
+    // update page
+    return pageData.save();
+  };
+
   pageSchema.statics.getHistories = function() {
     // TODO
     return;

+ 92 - 0
lib/routes/hackmd.js

@@ -0,0 +1,92 @@
+const logger = require('@alias/logger')('growi:routes:hackmd');
+const path = require('path');
+const swig = require('swig-templates');
+const axios = require('axios');
+
+const ApiResponse = require('../util/apiResponse');
+
+module.exports = function(crowi, app) {
+  const Page = crowi.models.Page;
+
+  // load GROWI agent script for HackMD
+  const manifest = require(path.join(crowi.publicDir, 'manifest.json'));
+  const agentScriptPath = path.join(crowi.publicDir, manifest['js/agent-for-hackmd.js']);
+  // generate swig template
+  const agentScriptContentTpl = swig.compileFile(agentScriptPath);
+
+
+  /**
+   * loadAgent action
+   * This should be access from HackMD and send agent script
+   *
+   * @param {object} req
+   * @param {object} res
+   */
+  const loadAgent = function(req, res) {
+    const origin = `${req.protocol}://${req.get('host')}`;
+    const styleFilePath = origin + manifest['styles/style-hackmd.css'];
+
+    // generate definitions to replace
+    const definitions = {
+      origin,
+      styleFilePath,
+    };
+    // inject
+    const script = agentScriptContentTpl(definitions);
+
+    res.set('Content-Type', 'application/javascript');
+    res.send(script);
+  };
+
+  /**
+   * Create page on HackMD and start to integrate
+   * @param {object} req
+   * @param {object} res
+   */
+  const integrate = async function(req, res) {
+    // validate process.env.HACKMD_URI
+    const hackMdUri = process.env.HACKMD_URI;
+    if (hackMdUri == null) {
+      return res.json(ApiResponse.error('HackMD for GROWI has not been setup'));
+    }
+    // validate pageId
+    const pageId = req.body.pageId;
+    if (pageId == null) {
+      return res.json(ApiResponse.error('pageId required'));
+    }
+    // validate page
+    const page = await Page.findOne({ _id: pageId });
+    if (page == null) {
+      return res.json(ApiResponse.error(`Page('${pageId}') does not exist`));
+    }
+    if (page.pageIdOnHackmd != null) {
+      return res.json(ApiResponse.error(`'pageIdOnHackmd' of the page '${page.path}' is not empty`));
+    }
+
+    // access to HackMD and create page
+    const response = await axios.get(`${hackMdUri}/new`);
+    logger.debug('HackMD responds', response);
+
+    // extract page id on HackMD
+    const pagePathOnHackmd = response.request.path;     // e.g. '/NC7bSRraT1CQO1TO7wjCPw'
+    const pageIdOnHackmd = pagePathOnHackmd.substr(1);  // strip the head '/'
+
+    // persist
+    try {
+      await Page.registerHackmdPage(page, pageIdOnHackmd);
+
+      const data = {
+        pageIdOnHackmd,
+      };
+      return res.json(ApiResponse.success(data));
+    }
+    catch (err) {
+      return res.json(ApiResponse.error(err));
+    }
+  };
+
+  return {
+    loadAgent,
+    integrate,
+  };
+};

+ 5 - 1
lib/routes/index.js

@@ -1,5 +1,5 @@
 module.exports = function(crowi, app) {
-  var middleware = require('../util/middlewares')
+  const middleware = require('../util/middlewares')
     , multer    = require('multer')
     , uploads   = multer({dest: crowi.tmpDir + 'uploads'})
     , form      = require('../form')
@@ -16,6 +16,7 @@ module.exports = function(crowi, app) {
     , bookmark  = require('./bookmark')(crowi, app)
     , revision  = require('./revision')(crowi, app)
     , search    = require('./search')(crowi, app)
+    , hackmd    = require('./hackmd')(crowi, app)
     , loginRequired = middleware.loginRequired
     , accessTokenParser = middleware.accessTokenParser(crowi, app)
     , csrf      = middleware.csrfVerify(crowi, app)
@@ -200,6 +201,9 @@ module.exports = function(crowi, app) {
   app.get('/trash/$'                 , loginRequired(crowi, app, false) , page.trashPageListShowWrapper);
   app.get('/trash/*/$'               , loginRequired(crowi, app, false) , page.deletedPageListShowWrapper);
 
+  app.get('/_hackmd/load-agent'      , hackmd.loadAgent);
+  app.post('/_api/hackmd/integrate'  , accessTokenParser , loginRequired(crowi, app) , csrf, hackmd.integrate);
+
   app.get('/*/$'                   , loginRequired(crowi, app, false) , page.pageListShowWrapper);
   app.get('/*'                     , loginRequired(crowi, app, false) , page.pageShowWrapper);
 };

+ 5 - 0
lib/routes/page.js

@@ -179,6 +179,8 @@ module.exports = function(crowi, app) {
 
       if (portalPage) {
         renderVars.revision = portalPage.revision;
+        renderVars.revisionHackmdSynced = portalPage.revisionHackmdSynced;
+        renderVars.pageIdOnHackmd = portalPage.pageIdOnHackmd;
         return Revision.findRevisionList(portalPage.path, {});
       }
       else {
@@ -255,6 +257,7 @@ module.exports = function(crowi, app) {
       tree: [],
       pageRelatedGroup: null,
       template: null,
+      revisionHackmdSynced: null,
       slack: '',
     };
 
@@ -278,6 +281,8 @@ module.exports = function(crowi, app) {
         renderVars.path = page.path;
         renderVars.revision = page.revision;
         renderVars.author = page.revision.author;
+        renderVars.revisionHackmdSynced = page.revisionHackmdSynced;
+        renderVars.pageIdOnHackmd = page.pageIdOnHackmd;
 
         return Revision.findRevisionList(page.path, {})
         .then(function(tree) {

+ 1 - 4
lib/views/_form.html

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

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

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

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

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

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

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

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

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

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

@@ -11,11 +11,18 @@
   </li>
 
   {% if !isTrashPage() %}
-  <li class="nav-main-left-tab {% if req.body.pageForm %}active{% endif %}">
-    <a {% if user %}href="#edit-form" data-toggle="tab"{% endif %} class="edit-button {% if not user %}edit-button-disabled{% endif %}">
+  <li class="nav-main-left-tab nav-tab-edit {% if req.body.pageForm %}active{% endif %}">
+    <a {% if user %}href="#edit" data-toggle="tab"{% endif %} class="edit-button {% if not user %}edit-button-disabled{% endif %}">
       <i class="icon-note"></i> {{ t('Edit') }}
     </a>
   </li>
+  {# disabled temporary -- 2018.07.06 Yuki Takei
+  <li class="nav-main-left-tab nav-tab-hackmd">
+    <a {% if user %}href="#hackmd" data-toggle="tab"{% endif %} class="{% if not user %}edit-button-disabled{% endif %}">
+      <i class="fa fa-file-text-o"></i> {{ t('HackMD') }}
+    </a>
+  </li>
+  #}
   {% endif %}
 
   {#

+ 4 - 2
package.json

@@ -116,6 +116,7 @@
     "babel-core": "^6.25.0",
     "babel-loader": "^7.1.1",
     "babel-plugin-lodash": "^3.3.2",
+    "babel-plugin-transform-runtime": "^6.23.0",
     "babel-polyfill": "^6.26.0",
     "babel-preset-env": "^1.6.0",
     "babel-preset-react": "^6.24.1",
@@ -167,13 +168,14 @@
     "optimize-css-assets-webpack-plugin": "^4.0.2",
     "plantuml-encoder": "^1.2.5",
     "postcss-loader": "^2.1.3",
-    "react": "^16.2.0",
+    "react": "^16.4.1",
     "react-bootstrap": "^0.32.1",
     "react-bootstrap-typeahead": "=3.0.4",
     "react-clipboard.js": "^2.0.0",
     "react-codemirror2": "^5.0.4",
-    "react-dom": "^16.2.0",
+    "react-dom": "^16.4.1",
     "react-dropzone": "^4.2.7",
+    "react-frame-component": "^4.0.0",
     "react-i18next": "^7.6.1",
     "reveal.js": "^3.5.0",
     "sass-loader": "^7.0.1",

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

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

+ 31 - 0
resource/js/app.js

@@ -16,6 +16,7 @@ import PageEditor       from './components/PageEditor';
 import OptionsSelector  from './components/PageEditor/OptionsSelector';
 import { EditorOptions, PreviewOptions } from './components/PageEditor/OptionsSelector';
 import GrantSelector    from './components/PageEditor/GrantSelector';
+import PageEditorByHackmd from './components/PageEditorByHackmd';
 import Page             from './components/Page';
 import PageListSearch   from './components/PageListSearch';
 import PageHistory      from './components/PageHistory';
@@ -50,6 +51,8 @@ const mainContent = document.querySelector('#content-main');
 let pageId = null;
 let pageRevisionId = null;
 let pageRevisionCreatedAt = null;
+let pageRevisionIdHackmdSynced = null;
+let pageIdOnHackmd = null;
 let pagePath;
 let pageContent = '';
 let markdown = '';
@@ -59,6 +62,8 @@ if (mainContent !== null) {
   pageId = mainContent.getAttribute('data-page-id');
   pageRevisionId = mainContent.getAttribute('data-page-revision-id');
   pageRevisionCreatedAt = +mainContent.getAttribute('data-page-revision-created');
+  pageRevisionIdHackmdSynced = mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null;
+  pageIdOnHackmd = mainContent.getAttribute('data-page-id-on-hackmd') || null;
   pagePath = mainContent.attributes['data-path'].value;
   slackChannels = mainContent.getAttribute('data-slack-channels');
   const rawText = document.getElementById('raw-text-original');
@@ -258,6 +263,32 @@ if (pageEditorGrantSelectorElem) {
   );
 }
 
+/*
+ * HackMD Editor
+ */
+// render PageEditorWithHackmd
+const pageEditorWithHackmdElem = document.getElementById('page-editor-with-hackmd');
+if (pageEditorWithHackmdElem) {
+  // create onSave event handler
+  const onSaveSuccess = function(page) {
+    // modify the revision id value to pass checking id when updating
+    crowi.getCrowiForJquery().updatePageForm(page);
+    // re-render Page component if exists
+    if (componentInstances.page != null) {
+      componentInstances.page.setMarkdown(page.revision.body);
+    }
+  };
+
+  pageEditor = ReactDOM.render(
+    <PageEditorByHackmd crowi={crowi}
+        pageId={pageId} revisionId={pageRevisionId}
+        revisionIdHackmdSynced={pageRevisionIdHackmdSynced} pageIdOnHackmd={pageIdOnHackmd}
+        markdown={markdown}
+        onSaveSuccess={onSaveSuccess} />,
+    pageEditorWithHackmdElem
+  );
+}
+
 // render for admin
 const customCssEditorElem = document.getElementById('custom-css-editor');
 if (customCssEditorElem != null) {

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

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

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

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

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

@@ -0,0 +1,115 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import * as toastr from 'toastr';
+
+import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
+
+export default class PageEditorByHackmd extends React.PureComponent {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isInitializing: false,
+      pageIdOnHackmd: this.props.pageIdOnHackmd,
+    };
+
+    this.getHackmdUri = this.getHackmdUri.bind(this);
+    this.startIntegrationWithHackmd = this.startIntegrationWithHackmd.bind(this);
+
+    this.apiErrorHandler = this.apiErrorHandler.bind(this);
+  }
+
+  componentWillMount() {
+  }
+
+  getHackmdUri() {
+    const envVars = this.props.crowi.config.env;
+    return envVars.HACKMD_URI;
+  }
+
+  syncToLatestRevision() {
+
+  }
+
+  /**
+   * Start integration with HackMD
+   */
+  startIntegrationWithHackmd() {
+    const hackmdUri = this.getHackmdUri();
+
+    if (hackmdUri == null) {
+      // do nothing
+      return;
+    }
+
+    this.setState({isInitializing: true});
+
+    const params = {
+      pageId: this.props.pageId,
+    };
+    this.props.crowi.apiPost('/hackmd/integrate', params)
+      .then(res => {
+        if (!res.ok) {
+          throw new Error(res.error);
+        }
+
+        this.setState({pageIdOnHackmd: res.pageIdOnHackmd});
+      })
+      .catch(this.apiErrorHandler)
+      .then(() => {
+        this.setState({isInitializing: false});
+      });
+  }
+
+  apiErrorHandler(error) {
+    toastr.error(error.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
+  }
+
+  render() {
+    const hackmdUri = this.getHackmdUri();
+
+    if (hackmdUri == null || this.state.pageIdOnHackmd == null) {
+      return (
+        <div className="hackmd-nopage d-flex justify-content-center align-items-center">
+          <div>
+            <p className="text-center">
+              <button className="btn btn-success btn-lg waves-effect waves-light" type="button"
+                  onClick={() => this.startIntegrationWithHackmd()} disabled={this.state.isInitializing}>
+                <span className="btn-label"><i className="fa fa-file-text-o"></i></span>
+                Start to edit with HackMD
+              </button>
+            </p>
+            <p className="text-center">Clone this page and start to edit with multiple peoples.</p>
+          </div>
+        </div>
+      );
+    }
+
+    return (
+      <HackmdEditor
+        markdown={this.props.markdown}
+        hackmdUri={hackmdUri}
+        pageIdOnHackmd={this.state.pageIdOnHackmd}
+      >
+      </HackmdEditor>
+    );
+  }
+}
+
+PageEditorByHackmd.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  markdown: PropTypes.string.isRequired,
+  pageId: PropTypes.string,
+  revisionId: PropTypes.string,
+  revisionIdHackmdSynced: PropTypes.string,
+  pageIdOnHackmd: PropTypes.string,
+};

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

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

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

@@ -10,12 +10,23 @@ if (!pageId) {
   }
 }
 
-$('a[data-toggle="tab"][href="#edit-form"]').on('show.bs.tab', function() {
+$('a[data-toggle="tab"][href="#edit"]').on('show.bs.tab', function() {
   $('body').addClass('on-edit');
+  $('body').addClass('builtin-editor');
 });
 
-$('a[data-toggle="tab"][href="#edit-form"]').on('hide.bs.tab', function() {
+$('a[data-toggle="tab"][href="#edit"]').on('hide.bs.tab', function() {
   $('body').removeClass('on-edit');
+  $('body').removeClass('builtin-editor');
+});
+$('a[data-toggle="tab"][href="#hackmd"]').on('show.bs.tab', function() {
+  $('body').addClass('on-edit');
+  $('body').addClass('hackmd');
+});
+
+$('a[data-toggle="tab"][href="#hackmd"]').on('hide.bs.tab', function() {
+  $('body').removeClass('on-edit');
+  $('body').removeClass('hackmd');
 });
 
 /**

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

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

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

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

+ 241 - 94
resource/styles/scss/_on-edit.scss

@@ -1,5 +1,25 @@
+body:not(.on-edit) {
+  // hide #page-form
+  #page-form {
+    display: none;
+  }
+}
+
 body.on-edit {
 
+  %expand-by-flex {
+    display: flex;
+    flex-direction: column;
+    flex: 1;
+  }
+
+  // calculate margin
+  $header-plus-footer: 2px                      // .main padding-top
+                      + 42px                    // .nav height
+                      + 1px                     // .page-editor-footer border-top
+                      + 40px;                   // .page-editor-footer min-height
+  $editor-margin: $header-plus-footer + 22px;   // .btn-open-dropzone height
+
   // hide unnecessary elements
   .navbar.navbar-static-top,
   .row.row-alerts,
@@ -29,15 +49,17 @@ body.on-edit {
     display: none;
   }
 
+  // show only either Edit button or HackMD button
+  &.hackmd .nav-tab-edit {
+    display: none;
+  }
+  &:not(.hackmd) .nav-tab-hackmd {
+    display: none;
+  }
 
   /*****************
    * Expand Editor
    *****************/
-  .expand-by-flex {
-    display: flex;
-    flex-direction: column;
-    flex: 1;
-  }
   .container-fluid {
     padding-bottom: 0;
   }
@@ -60,54 +82,8 @@ body.on-edit {
 
     &,
     .content-main,
-    .tab-content,
-    .edit-form,
-    .page-form {
-      @extend .expand-by-flex;
-    }
-
-    .page-form {
-
-      // calculate margin
-      $header-plus-footer: 2px                      // .main padding-top
-                         + 42px                     // .nav height
-                         + 1px                      // .page-editor-footer border-top
-                         + 40px;                    // .page-editor-footer min-height
-      $editor-margin: $header-plus-footer + 22px;   // .btn-open-dropzone height
-
-      #page-editor {
-        // right(preview)
-        &,
-        .row,
-        .page-editor-preview-container,
-        .page-editor-preview-body {
-          min-height: calc(100vh - #{$header-plus-footer});   // for IE11
-          height: calc(100vh - #{$header-plus-footer});
-        }
-        // left(editor)
-        .page-editor-editor-container {
-          min-height: calc(100vh - #{$header-plus-footer});   // for IE11
-          height: calc(100vh - #{$header-plus-footer});
-
-          .react-codemirror2, .CodeMirror, .CodeMirror-scroll,
-          .textarea-editor {
-            height: calc(100vh - #{$editor-margin});
-          }
-        }
-      }
-
-
-      .page-editor-footer {
-        width: 100%;
-        margin: 0;
-        padding: 3px;
-        min-height: 40px;
-        border-top: solid 1px transparent;
-
-        .btn-submit {
-          width: 100px;
-        }
-      }
+    .tab-content {
+      @extend %expand-by-flex;
     }
   }
 
@@ -150,68 +126,239 @@ body.on-edit {
     }
   }
 
-  /*****************
-   * Editor styles
-   *****************/
-  .page-editor-editor-container {
-    border-right: 1px solid transparent;
-    padding-right: 0;
-    // override CodeMirror styles
-    .CodeMirror {
-      .cm-matchhighlight {
-        background-color: cyan;
-      }
-      .CodeMirror-selection-highlight-scrollbar {
-        background-color: darkcyan;
-      }
-    }
+  .page-editor-footer {
+    width: 100%;
+    margin: 0;
+    padding: 3px;
+    min-height: 40px;
+    border-top: solid 1px transparent;
 
-  }
-  .page-editor-preview-container {
+    .btn-submit {
+      width: 100px;
+    }
   }
 
-  .page-editor-preview-body {
-    padding-top: 18px;
-    padding-right: 15px;
-    overflow-y: scroll;
-  }
 
-  #page-editor-options-selector {
-    label {
-      margin-right: 0.5em;
+  &.builtin-editor .tab-pane#edit {
+    @extend %expand-by-flex;
+
+    #page-editor {
+      // right(preview)
+      &,
+      .row,
+      .page-editor-preview-container,
+      .page-editor-preview-body {
+        min-height: calc(100vh - #{$header-plus-footer});   // for IE11
+        height: calc(100vh - #{$header-plus-footer});
+      }
+      // left(editor)
+      .page-editor-editor-container {
+        min-height: calc(100vh - #{$header-plus-footer});   // for IE11
+        height: calc(100vh - #{$header-plus-footer});
+
+        .react-codemirror2, .CodeMirror, .CodeMirror-scroll,
+        .textarea-editor {
+          height: calc(100vh - #{$editor-margin});
+        }
+      }
     }
 
-    // configuration dropdown
-    .configuration-dropdown {
-      .icon-container {
-        display: inline-block;
-        width: 20px;
+    /*****************
+    * Editor styles
+    *****************/
+    .page-editor-editor-container {
+      border-right: 1px solid transparent;
+      padding-right: 0;
+      // override CodeMirror styles
+      .CodeMirror {
+        .cm-matchhighlight {
+          background-color: cyan;
+        }
+        .CodeMirror-selection-highlight-scrollbar {
+          background-color: darkcyan;
+        }
       }
-      .dropdown-menu > li > a {
+
+      .overlay {
+        // layout
         display: flex;
-        justify-content: space-between;
+        justify-content: center;
         align-items: center;
+        // style
+        margin: 0 15px;
+      }
+      .overlay-content {
+        font-size: 2.5em;
+        padding: 0.5em;
+      }
 
-        .menuitem-label {
-          flex: 1;
-          margin-right: 10px;
+      @mixin overlay-processing-style() {
+        .overlay {
+          background: rgba(255,255,255,0.5);
         }
+        .overlay-content {
+          padding: 0.3em;
+          background: rgba(200,200,200,0.5);
+          color: #444;
+        }
+      }
+      // add icon on cursor
+      .autoformat-markdown-table-activated .CodeMirror-cursor {
+        &:after {
+          font-family: 'FontAwesome';
+          content: '\f0ce';
+        }
+      }
+
+      // for Dropzone
+      .dropzone {
+        @mixin insertSimpleLineIcons($code) {
+          &:before {
+            margin-right: 0.2em;
+            font-family: 'simple-line-icons';
+            content: $code;
+          }
+        }
+
+        // unuploadable or rejected
+        &.dropzone-unuploadable, &.dropzone-rejected {
+          .overlay {
+            background: rgba(200,200,200,0.8);
+          }
+          .overlay-content {
+            color: #444;
+          }
+        }
+        // uploading
+        &.dropzone-uploading {
+          @include overlay-processing-style();
+        }
+
+        // unuploadable
+        &.dropzone-unuploadable {
+          .overlay-content {
+            // insert content
+            @include insertSimpleLineIcons("\e617");  // icon-exclamation
+            &:after {
+              content: "File uploading is disabled";
+            }
+          }
+        }
+        // uploadable
+        &.dropzone-uploadable {
+          // accepted
+          &.dropzone-accepted:not(.dropzone-rejected) {
+            .overlay {
+              border: 4px dashed #ccc;
+            }
+            .overlay-content {
+              // insert content
+              @include insertSimpleLineIcons("\e084");  // icon-cloud-upload
+              &:after {
+                content: "Drop here to upload";
+              }
+              // style
+              color: #666;
+              background: rgba(200,200,200,0.8);
+            }
+          }
+          // file type mismatch
+          &.dropzone-rejected:not(.dropzone-uploadablefile) .overlay-content {
+            // insert content
+            @include insertSimpleLineIcons("\e032");  // icon-picture
+            &:after {
+              content: "Only an image file is allowed";
+            }
+          }
+          // multiple files
+          &.dropzone-accepted.dropzone-rejected .overlay-content {
+            // insert content
+            @include insertSimpleLineIcons("\e617");  // icon-exclamation
+            &:after {
+              content: "Only 1 file is allowed";
+            }
+          }
+        }
+      } // end of.dropzone
+
+      .textarea-editor {
+        border: none;
+        font-family: monospace;
+      }
+
+      .loading-keymap {
+        @include overlay-processing-style();
       }
+
+    }
+    .page-editor-preview-container {
     }
 
-    @media (max-width: $screen-xs-max) { // {{{ less than smartphone size
+    .page-editor-preview-body {
+      padding-top: 18px;
+      padding-right: 15px;
+      overflow-y: scroll;
+    }
+
+    #page-editor-options-selector {
+      label {
+        margin-right: 0.5em;
+      }
+
+      // configuration dropdown
+      .configuration-dropdown {
+        .icon-container {
+          display: inline-block;
+          width: 20px;
+        }
+        .dropdown-menu > li > a {
+          display: flex;
+          justify-content: space-between;
+          align-items: center;
+
+          .menuitem-label {
+            flex: 1;
+            margin-right: 10px;
+          }
+        }
+      }
+
+      @media (max-width: $screen-xs-max) { // {{{ less than smartphone size
+        display: none;
+      }
+    }
+
+    #page-grant-selector {
+      .btn-group {
+        min-width: 150px;
+      }
+    }
+
+  } // .builtin-editor .tab-pane#edit
+
+
+  &.hackmd {
+    #page-editor-options-selector {
       display: none;
     }
-  }
 
-  #page-grant-selector {
-    .btn-group {
-      min-width: 150px;
+    .tab-pane#hackmd {
+      @extend %expand-by-flex;
+
+      #hackmd-editor,
+      .hackmd-nopage, #iframe-hackmd {
+        width: 100vw;
+        min-height: calc(100vh - #{$header-plus-footer});   // for IE11
+        height: calc(100vh - #{$header-plus-footer});
+
+        border: none;
+      }
     }
+
   }
 
+}
 
-} // }}}
 
 /*
  * for creating portal

+ 16 - 6
yarn.lock

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