Browse Source

Merge pull request #258 from weseek/master

release v2.4.0
Yuki Takei 8 years ago
parent
commit
720d544923
66 changed files with 1654 additions and 721 deletions
  1. 18 5
      CHANGES.md
  2. 34 35
      README.md
  3. 30 0
      bin/shrink-emojione-strategy.js
  4. 1 1
      config/env.dev.js
  5. 2 3
      config/webpack.common.js
  6. 16 0
      lib/crowi/dev.js
  7. 17 13
      lib/crowi/index.js
  8. 1 1
      lib/util/googleAuth.js
  9. 6 8
      lib/util/i18nUserSettingDetector.js
  10. 6 2
      lib/util/middlewares.js
  11. 12 1
      lib/views/admin/customize.html
  12. 3 1
      lib/views/crowi-plus/base/user_page_nosidebar.html
  13. 3 1
      lib/views/crowi-plus/page.html
  14. 3 1
      lib/views/crowi-plus/page_list.html
  15. 1 1
      lib/views/crowi-plus/widget/comments.html
  16. 1 1
      lib/views/crowi-plus/widget/page_list_container.html
  17. 22 0
      lib/views/layout/layout.html
  18. 2 2
      lib/views/page.html
  19. 2 4
      lib/views/page_list.html
  20. 6 2
      lib/views/page_presentation.html
  21. 12 5
      package.json
  22. 10 3
      resource/css/_comment_crowi-plus.scss
  23. 1 1
      resource/css/_form.scss
  24. 2 2
      resource/css/_user_crowi-plus.scss
  25. 23 70
      resource/css/_wiki.scss
  26. 1 1
      resource/css/_wiki_crowi-plus.scss
  27. 1 1
      resource/css/crowi-reveal.scss
  28. 51 21
      resource/js/app.js
  29. 131 0
      resource/js/components/Page.js
  30. 0 70
      resource/js/components/Page/PageBody.js
  31. 66 0
      resource/js/components/Page/RevisionBody.js
  32. 136 37
      resource/js/components/PageEditor.js
  33. 35 6
      resource/js/components/PageEditor/Editor.js
  34. 0 99
      resource/js/components/PageEditor/EditorOptionsSelector.js
  35. 12 21
      resource/js/components/PageEditor/EmojiAutoCompleteHelper.js
  36. 161 0
      resource/js/components/PageEditor/OptionsSelector.js
  37. 28 7
      resource/js/components/PageEditor/Preview.js
  38. 195 0
      resource/js/components/PageEditor/ScrollSyncHelper.js
  39. 3 2
      resource/js/components/SearchPage.js
  40. 2 1
      resource/js/components/SearchPage/SearchResult.js
  41. 14 18
      resource/js/components/SearchPage/SearchResultList.js
  42. 1 1
      resource/js/components/SearchTypeahead.js
  43. 2 2
      resource/js/legacy/crowi-form.js
  44. 0 1
      resource/js/legacy/crowi-presentation.js
  45. 58 108
      resource/js/legacy/crowi.js
  46. 6 0
      resource/js/util/Crowi.js
  47. 12 3
      resource/js/util/CrowiRenderer.js
  48. 166 0
      resource/js/util/GrowiRenderer.js
  49. 0 43
      resource/js/util/LangProcessor/PlantUML.js
  50. 12 4
      resource/js/util/LangProcessor/Template.js
  51. 0 85
      resource/js/util/LangProcessor/Tsv2Table.js
  52. 5 0
      resource/js/util/PostProcessor/Emoji.js
  53. 5 0
      resource/js/util/PostProcessor/Mathjax.js
  54. 26 0
      resource/js/util/PreProcessor/CsvToTable.js
  55. 0 0
      resource/js/util/emojione/emoji_strategy_shrinked.json
  56. 2 2
      resource/js/util/interceptor/detach-code-blocks.js
  57. 15 0
      resource/js/util/markdown-it/common-plugins.js
  58. 27 0
      resource/js/util/markdown-it/emoji.js
  59. 34 0
      resource/js/util/markdown-it/header-line-number.js
  60. 36 0
      resource/js/util/markdown-it/header.js
  61. 16 0
      resource/js/util/markdown-it/mathjax.js
  62. 26 0
      resource/js/util/markdown-it/plantuml.js
  63. 13 0
      resource/js/util/markdown-it/table.js
  64. 38 0
      resource/js/util/markdown-it/toc-and-anchor.js
  65. 0 1
      resource/styles/index.js
  66. 84 24
      yarn.lock

+ 18 - 5
CHANGES.md

@@ -1,7 +1,20 @@
 CHANGES
 ========
 
-## 2.3.9-RC
+## 2.4.0-RC2
+
+* Feature: Support Footnotes
+* Feature: Support Task lists
+* Feature: Support Table with CSV
+* Feature: Enable to render UML diagrams with public plantuml.com server
+* Feature: Enable to switch whether rendering MathJax in realtime or not
+* Improvement: Replace markdown parser with markdown-it
+* Improvement: Generate anchor of headers with header strings
+* Improvement: Enhanced Scroll Sync on Markdown Editor/Preview
+* Improvement: Update `#revision-body` tab contents after saving with `Ctrl-S`
+* Fix: 500 Internal Server Error occures when basic-auth configuration is set
+
+## 2.3.9
 
 * Fix: `Ctrl-/` doesn't work on Chrome
 * Fix: Close Shortcuts help with `Ctrl-/`, ESC key
@@ -266,8 +279,8 @@ CHANGES
 
 ## 1.1.8
 
-* Fix: Depth of dropdown-menu when '.on-edit'
-* Fix: Error occured on saveing with Ctrl+S
+* Fix: Depth of dropdown-menu when `.on-edit`
+* Fix: Error occured on saveing with `Ctrl-S`
 * Fix: Guest users browsing
 
 ## 1.1.7
@@ -296,11 +309,11 @@ CHANGES
 ## 1.1.2
 
 * Imprv: Brushup fonts and styles
-* Fix: Ensure to specity revision id when saving with Ctrl+S
+* Fix: Ensure to specity revision id when saving with `Ctrl-S`
 
 ## 1.1.1
 
-* Feature: Save with Ctrl+S
+* Feature: Save with `Ctrl-S`
 * Imprv: Brushup fonts and styles
 
 ## 1.1.0

+ 34 - 35
README.md

@@ -13,18 +13,19 @@ crowi-plus [![Chat on Slack](https://crowi-plus-slackin.weseek.co.jp/badge.svg)]
 [![wercker status](https://app.wercker.com/status/39cdc49d067d65c39cb35d52ceae6dc1/s/master "wercker status")](https://app.wercker.com/project/byKey/39cdc49d067d65c39cb35d52ceae6dc1)
 [![dependencies status](https://david-dm.org/weseek/crowi-plus.svg)](https://david-dm.org/weseek/crowi-plus)
 [![devDependencies Status](https://david-dm.org/weseek/crowi-plus/dev-status.svg)](https://david-dm.org/weseek/crowi-plus?type=dev)
+[![docker pulls](https://img.shields.io/docker/pulls/weseek/crowi-plus.svg)](https://hub.docker.com/r/weseek/crowi-plus/)
 [![MIT License](http://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
 
 
-**crowi-plus** is a fork of [Crowi][crowi] that is [perfectly compatible with the official project](https://github.com/weseek/crowi-plus/wiki/Correspondence-table-with-official-version).
+**crowi-plus** is a fork of [Crowi][crowi] which is [perfectly compatible with the official project](https://github.com/weseek/crowi-plus/wiki/Correspondence-table-with-Crowi-version).
 
 
 Why crowi-plus?
 ================
 
 * **Pluggable**
-  * Find plugins from [npm](https://www.npmjs.com/browse/keyword/crowi-plugin) or [github](https://github.com/search?q=topic%3Acrowi-plugin)!
-* **Faster**
+  * You can find plugins from [npm](https://www.npmjs.com/browse/keyword/crowi-plugin) or [github](https://github.com/search?q=topic%3Acrowi-plugin)!
+* **Fast**
   * Optimize client-side code chunks by Webpack
   * Optimize the performance when live preview
   * Adopt faster libs([date-fns](https://github.com/date-fns/date-fns), [pino](https://github.com/pinojs/pino))
@@ -47,7 +48,7 @@ Why crowi-plus?
   * LiveReload separately available by server/client code change
   * Exclude Environment-dependency (confirmed to be developable on Win/Mac/Linux)
 
-Check it out all additional features from [**here**](https://github.com/weseek/crowi-plus/wiki/Additional-Features).
+Check out all additional features from [**here**](https://github.com/weseek/crowi-plus/wiki/Additional-Features).
 
 
 Quick Start for Production
@@ -92,7 +93,7 @@ See [confirmed versions](https://github.com/weseek/crowi-plus/wiki/Developers-Gu
       - [Japanese (kuromoji) Analysis plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html)
       - [ICU Analysis Plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-icu.html)
 
-### Start
+### How to start
 
 #### Build and run the app
 
@@ -103,7 +104,7 @@ yarn
 MONGO_URI=mongodb://MONGO_HOST:MONGO_PORT/crowi npm start
 ```
 
-**DON'T USE `npm install`**, use `yarn` instead.
+**DO NOT USE `npm install`**, use `yarn` instead.
 
 If you launch crowi-plus with Redis and ElasticSearch, add environment variables before `npm start` like following:
 
@@ -114,7 +115,7 @@ export ELASTICSEARCH_URI=http://ELASTICSEARCH_HOST:ELASTICSEARCH_PORT/crowi
 npm start
 ```
 
-For more info, check [Developers Guide](https://github.com/weseek/crowi-plus/wiki/Developers-Guide) and [the official documents](https://github.com/crowi/crowi/wiki/Install-and-Configuration#env-parameters).
+For more info, see [Developers Guide](https://github.com/weseek/crowi-plus/wiki/Developers-Guide) and [the official documents](https://github.com/crowi/crowi/wiki/Install-and-Configuration#env-parameters).
 
 #### Command details
 
@@ -124,7 +125,7 @@ For more info, check [Developers Guide](https://github.com/weseek/crowi-plus/wik
 |`npm run server:prod`|Launch the server|
 |`npm start`|Invoke `npm run build:prod` and `npm run server:prod`|
 
-### Upgrade
+### How to upgrade
 
 ```bash
 git pull
@@ -132,14 +133,14 @@ yarn
 npm start
 ```
 
-### Install plugins
+### How to install plugins
 
-* Stop server if running
+* Stop server if server is running
 * `yarn add` to install plugin or `npm install --save`
   * **Don't forget `--save` option if you use npm** or crowi-plus doesn't detect plugins
 * `npm start` to build client app and start server
 
-#### Example
+#### Examples
 
 ```bash
 yarn add crowi-plugin-lsx
@@ -147,20 +148,8 @@ npm start
 ```
 
 
-Getting Started to Develop
-==========================
 
-## Build and Run the app
-
-1. `clone` this repository
-1. `yarn global add npm@4` to install required global dependencies
-1. `yarn` to install all dependencies
-    * DON'T USE `npm install`
-1. `npm run build` to build client app
-1. `npm run server` to start the dev server
-1. Access to `http://0.0.0.0:3000`
-
-For more info, read [Developers Guide](https://github.com/weseek/crowi-plus/wiki/Developers-Guide) on Wiki.
+For more info, see [Developers Guide](https://github.com/weseek/crowi-plus/wiki/Developers-Guide) on Wiki.
 
 
 Documentation
@@ -171,13 +160,26 @@ Documentation
   * [Migration Guide from Official Crowi](https://github.com/weseek/crowi-plus/wiki/Migration-Guide-from-Official-Crowi)
   * [Developers Guide](https://github.com/weseek/crowi-plus/wiki/Developers-Guide)
 
-Contributing
+Contribution
 ============
 
+For development
+-------------
+
+### Build and Run the app
+
+1. `clone` this repository
+1. `yarn global add npm@4` to install required global dependencies
+1. `yarn` to install all dependencies
+    * DO NOT USE `npm install`
+1. `npm run build` to build client app
+1. `npm run server` to start the dev server
+1. Access `http://0.0.0.0:3000`
+
 Found a Bug?
 -------------
 
-If you find a bug in the source code, you can help us by
+If you found a bug in the source code, you can help us by
 [submitting an issue][issues] to our [GitHub Repository][crowi-plus]. Even better, you can
 [submit a Pull Request][pulls] with a fix.
 
@@ -185,24 +187,21 @@ Missing a Feature?
 -------------------
 
 You can *request* a new feature by [submitting an issue][issues] to our GitHub
-Repository. If you would like to *implement* a new feature, please submit an issue with
-a proposal for your work first, to be sure that we can use it.
-Please consider what kind of change it is:
+Repository. If you would like to *implement* a new feature, firstly please submit the issue with your proposal to make sure we can confirm it. Please clarify what kind of change you would like to propose.
 
-* For a **Major Feature**, first open an issue and outline your proposal so that it can be
-discussed. This will also allow us to better coordinate our efforts, prevent duplication of work,
-and help you to craft the change so that it is successfully accepted into the project.
-* **Small Features** can be crafted and directly [submitted as a Pull Request][pulls].
+* For a **Major Feature**, firstly open an issue and outline your proposal so it can be discussed. 
+It also allows us to coordinate better, prevent duplication of work and help you to create the change so it can be successfully accepted into the project.
+* **Small Features** can be created and directly [submitted as a Pull Request][pulls].
 
 Language
 ---------
 
-Write issues and PRs in English or Japanese.
+You can write issues and PRs in English or Japanese.
 
 Discussion
 -----------
 
-If you have something to ask or want to discuss, [join to our Slack team][slackin] and talk about anything, anytime.
+If you have questions or suggestions, you can [join our Slack team][slackin] and talk about anything, anytime.
 
 
 License

+ 30 - 0
bin/shrink-emojione-strategy.js

@@ -0,0 +1,30 @@
+/**
+ * the tool to shrink emojione/emoji_strategy.json and output
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ */
+const fs = require('graceful-fs');
+const normalize = require('normalize-path');
+const helpers = require('../config/helpers');
+
+const OUT = helpers.root('tmp/emoji_strategy_shrinked.json');
+
+const emojiStrategy = require('emojione/emoji_strategy.json');
+const markdownItEmojiFull = require('markdown-it-emoji/lib/data/full.json');
+
+let shrinkedMap = {};
+for (let unicode in emojiStrategy) {
+  const data = emojiStrategy[unicode];
+  const shortname = data.shortname.replace(/\:/g, '');
+
+  // ignore if it isn't included in markdownItEmojiFull
+  if (markdownItEmojiFull[shortname] == null) {
+    continue;
+  }
+
+  // add
+  shrinkedMap[unicode] = data;
+}
+
+// write
+fs.writeFileSync(OUT, JSON.stringify(shrinkedMap));

+ 1 - 1
config/env.dev.js

@@ -12,7 +12,7 @@ module.exports = {
   DEBUG: [
     // 'express:*',
     // 'crowi:*',
-    // 'crowi:crowi',
+    'crowi:crowi',
     'crowi:crowi:dev',
     'crowi:crowi:express-init',
     'crowi:models:external-account',

+ 2 - 3
config/webpack.common.js

@@ -34,6 +34,7 @@ module.exports = function (options) {
       //  on the global var jQuery
       "jquery": "jQuery",
       "emojione": "emojione",
+      "hljs": "hljs",
     },
     resolve: {
       extensions: ['.js', '.json'],
@@ -97,11 +98,9 @@ module.exports = function (options) {
         chunks: ['commons', 'plugin'],
       }),
 
-      new webpack.ProvidePlugin({
+      new webpack.ProvidePlugin({ // refs externals
         jQuery: "jquery",
         $: "jquery",
-        hljs: "reveal.js/plugin/highlight/highlight",
-        emojione: "emojione",
       }),
 
     ]

+ 16 - 0
lib/crowi/dev.js

@@ -1,4 +1,5 @@
 const debug = require('debug')('crowi:crowi:dev');
+const fs = require('fs');
 const path = require('path');
 const webpack = require('webpack');
 const helpers = require('./helpers');
@@ -20,6 +21,8 @@ class CrowiDev {
   }
 
   init() {
+    this.requireForLiveReload();
+
     this.initPromiseRejectionWarningHandler();
     this.initSwig();
     this.hackLRWebSocketServer();
@@ -34,6 +37,19 @@ class CrowiDev {
     swig.setDefaults({ cache: false });
   }
 
+  /**
+   * require files for live reloading
+   */
+  requireForLiveReload() {
+    // environment file
+    require(path.join(this.crowi.rootDir, 'config', 'env.dev.js'));
+
+    // load all json files for live reloading
+    fs.readdirSync(this.crowi.localeDir).map((dirname) => {
+      require(path.join(this.crowi.localeDir, dirname, 'translation.json'));
+    });
+  }
+
   /**
    * prevent to crash socket with:
    * -------------------------------------------------

+ 17 - 13
lib/crowi/index.js

@@ -71,19 +71,7 @@ Crowi.prototype.init = function() {
   }).then(function() {
     return self.setupSessionConfig();
   }).then(function() {
-    return new Promise(function(resolve, reject) {
-      self.model('Config', require('../models/config')(self));
-      var Config = self.model('Config');
-      Config.loadAllConfig(function(err, doc) {
-        if (err) {
-          return reject();
-        }
-
-        self.setConfig(doc);
-
-        return resolve();
-      });
-    });
+    return self.setupAppConfig();
   }).then(function() {
     return self.scanRuntimeVersions();
   }).then(function() {
@@ -204,6 +192,22 @@ Crowi.prototype.setupSessionConfig = function() {
   });
 };
 
+Crowi.prototype.setupAppConfig = function() {
+  return new Promise((resolve, reject) => {
+    this.model('Config', require('../models/config')(this));
+    var Config = this.model('Config');
+    Config.loadAllConfig((err, doc) => {
+      if (err) {
+        return reject();
+      }
+
+      this.setConfig(doc);
+
+      return resolve();
+    });
+  });
+}
+
 Crowi.prototype.setupModels = function() {
   var self = this
     ;

+ 1 - 1
lib/util/googleAuth.js

@@ -59,7 +59,7 @@ module.exports = function(config) {
           return callback(new Error('[googleAuth.handleCallback] Error while proceccing userinfo.get.'), null);
         }
 
-        const data = response.data;
+        let data = response.data;
         data.user_id = data.id;           // This is for B.C. (tokeninfo をつかっている前提のコードに対してのもの)
         return callback(null, data);
       });

+ 6 - 8
lib/util/i18nUserSettingDetector.js

@@ -2,15 +2,13 @@ module.exports = {
   name: 'userSettingDetector',
 
   lookup: function(req, res, options) {
-    var lang = null;
-
-    if (req.user) {
-      if ('lang' in req.user) {
-        lang = req.user.lang || null;
-      }
+    // return null if
+    //  1. user doesn't logged in
+    //  2. req.user is username/email string to login which is set by basic-auth-connect
+    if (req.user == null || !(req.user instanceof Object)) {
+      return null;
     }
-
-    return lang;
+    return req.user.lang || null;
   },
 
   cacheUserlanguage: function(req, res, lng, options) {

+ 6 - 2
lib/util/middlewares.js

@@ -182,7 +182,9 @@ exports.swigFilters = function(app, swig) {
 
 exports.adminRequired = function() {
   return function(req, res, next) {
-    if (req.user && '_id' in req.user) {
+    // check the user logged in
+    //  make sure that req.user isn't username/email string to login which is set by basic-auth-connect
+    if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
       if (req.user.admin) {
         next();
         return;
@@ -215,7 +217,9 @@ exports.loginRequired = function(crowi, app, isStrictly = true) {
       }
     }
 
-    if (req.user && '_id' in req.user) {
+    // check the user logged in
+    //  make sure that req.user isn't username/email string to login which is set by basic-auth-connect
+    if (req.user != null && (req.user instanceof Object) && '_id' in req.user) {
       if (req.user.status === User.STATUS_ACTIVE) {
         // Active の人だけ先に進める
         return next();

+ 12 - 1
lib/views/admin/customize.html

@@ -248,8 +248,11 @@
         </p>
 
         <p class="help-block">
-          Available Placeholders:
+          Placeholders:<br>
+          (Available after <code>load</code> event)
           <dl class="dl-horizontal">
+            <dt><code>$</code></dt>
+            <dd>jQuery instance</dd>
             <dt><code>crowi</code></dt>
             <dd>Crowi context instance</dd>
             <dt><code>Crowi</code></dt>
@@ -260,6 +263,14 @@
             <dd>crowi-plus plugin manager instance</dd>
           </dl>
         </p>
+        <p class="help-block">
+          Examples:
+<pre><code>console.log($('.main-container'));
+
+window.addEventListener('load', (event) => {
+  console.log('config: ', crowi.config);
+});</code></pre>
+        </p>
 
         <div class="form-group">
           <div class="col-xs-12">

+ 3 - 1
lib/views/crowi-plus/base/user_page_nosidebar.html

@@ -46,7 +46,9 @@
 
         {# relocate #revision-toc #}
         <div class="col-lg-2 col-md-3 visible-lg visible-md">
-          <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="50"></div>
+          <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="54">
+            <div id="revision-toc-content" class="revision-toc-content"></div>
+          </div>
         </div> {# /.col- #}
 
       </div>

+ 3 - 1
lib/views/crowi-plus/page.html

@@ -34,7 +34,9 @@
 
       {# relocate #revision-toc #}
       <div class="col-lg-2 col-md-3 visible-lg visible-md">
-        <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="100"></div>
+        <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="128">
+          <div id="revision-toc-content" class="revision-toc-content"></div>
+        </div>
       </div> {# /.col- #}
 
     </div>

+ 3 - 1
lib/views/crowi-plus/page_list.html

@@ -38,7 +38,9 @@
 
       {# relocate #revision-toc #}
       <div class="col-lg-2 col-md-3 visible-lg visible-md">
-        <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="100"></div>
+        <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="100">
+          <div id="revision-toc-content" class="revision-toc-content"></div>
+        </div>
       </div> {# /.col- #}
 
     </div>

+ 1 - 1
lib/views/crowi-plus/widget/comments.html

@@ -27,7 +27,7 @@
         <div class="comment-form-main">
           <div class="comment-write" id="comment-write">
             <textarea class="comment-form-comment form-control" id="comment-form-comment" name="commentForm[comment]"
-                rows="10" placeholder="Write comments here..." {% if not user %}disabled{% endif %}></textarea>
+                required placeholder="Write comments here..." {% if not user %}disabled{% endif %}></textarea>
           </div>
           <div class="comment-submit">
             <input type="hidden" name="_csrf" value="{{ csrf() }}">

+ 1 - 1
lib/views/crowi-plus/widget/page_list_container.html

@@ -25,7 +25,7 @@
     {% if isEnabledTimeline() %}
     <div class="tab-pane" id="view-timeline" data-shown=0>
       {% for page in pages %}
-      <div class="timeline-body" id="id-{{ page.id }}">
+      <div class="timeline-body" id="id-{{ page.id }}" data-page-path="{{ page.path }}">
         <h3 class="revision-path"><a href="{{ page.path }}">{{ page.path }}</a></h3>
         <div class="revision-body wiki"></div>
         <script type="text/template">{{ page.revision.body }}</script>

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

@@ -35,6 +35,26 @@
 
   <!-- jQuery, emojione -->
   <script src="https://cdn.jsdelivr.net/combine/npm/emojione@3.1.2,npm/jquery@3.3.1"></script>
+  <!-- highlight.js -->
+  <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.12.0/build/highlight.min.js"></script>
+  {% if local_config.env.MATHJAX %}
+    <!-- Mathjax -->
+    <script type="text/x-mathjax-config" async>
+      MathJax.Hub.Config({
+        skipStartupTypeset: true,
+        extensions: ["tex2jax.js"],
+        jax: ["input/TeX", "output/SVG"],
+        tex2jax: {
+          processEscapes: true
+        },
+        showMathMenu: false,
+        showMathMenuMSIE: false,
+        showProcessingMessages: false,
+        messageStyle: "none"
+      });
+    </script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.2/MathJax.js" async></script>
+  {% endif %}
 
   {% if env === 'development' %}
     <script src="/dll/vendor.dll.js"></script>
@@ -57,6 +77,8 @@
   <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css">
   <!-- emojione -->
   <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/emojione@3.1.2/extras/css/emojione.min.css">
+  <!-- highlight.js -->
+  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@9.12.0/styles/github.css">
 
   {% block html_additional_headers %}{% endblock %}
 

+ 2 - 2
lib/views/page.html

@@ -189,9 +189,9 @@
     <div class="tab-pane {% if not req.body.pageForm %}active{% endif %}" id="revision-body">
       <div class="revision-toc" id="revision-toc">
         <a data-toggle="collapse" data-parent="#revision-toc" href="#revision-toc-content" class="revision-toc-head">{{ t('Table of Contents') }}</a>
-
+        <div id="revision-toc-content" class="revision-toc-content collapse in"></div>
       </div>
-      <div class="wiki" id="revision-body-content"></div>
+      <div id="page"></div>
     </div>
 
     {# edit form #}

+ 2 - 4
lib/views/page_list.html

@@ -128,9 +128,7 @@
   </div>
   {% endif %}
     <div class="tab-pane {% if not req.body.pageForm %}active{% endif %}" id="revision-body">
-      <div class="wiki" id="revision-body-content">
-        <i class="fa fa-spinner fa-pulse fa-fw"></i>
-      </div>
+      <div id="page"></div>
     </div>
 
     <script type="text/template" id="raw-text-original">{{ page.revision.body }}</script>
@@ -214,7 +212,7 @@
     {% if isEnabledTimeline() %}
     <div class="tab-pane" id="view-timeline" data-shown=0>
       {% for page in pages %}
-      <div class="timeline-body" id="id-{{ page.id }}">
+      <div class="timeline-body" id="id-{{ page.id }}" data-page-path="{{ page.path }}">
         <h3 class="revision-path"><a href="{{ page.path }}">{{ page.path }}</a></h3>
         <div class="revision-body wiki"></div>
         <script type="text/template">{{ page.revision.body }}</script>

+ 6 - 2
lib/views/page_presentation.html

@@ -17,8 +17,10 @@
       }
     </script>
 
-    <!-- jQuery -->
-    <script src="//cdn.jsdelivr.net/jquery/3.2.1/jquery.min.js"></script>
+    <!-- jQuery, emojione (expect to hit the cache) -->
+    <script src="https://cdn.jsdelivr.net/combine/npm/emojione@3.1.2,npm/jquery@3.3.1"></script>
+    <!-- highlight.js -->
+    <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.12.0/build/highlight.min.js"></script>
 
     {% if env === 'development' %}
       <script src="/dll/vendor.dll.js"></script>
@@ -34,6 +36,8 @@
 
     <!-- Google Fonts -->
     <link href='https://fonts.googleapis.com/css?family=Lato:400,700' rel='stylesheet' type='text/css'>
+    <!-- highlight.js -->
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@9.12.0/styles/github.css">
 
     <style>
       {{ customCss() }}

+ 12 - 5
package.json

@@ -1,6 +1,6 @@
 {
   "name": "crowi-plus",
-  "version": "2.3.9-RC",
+  "version": "2.4.0-RC2",
   "description": "Enhanced Crowi",
   "tags": [
     "wiki",
@@ -68,6 +68,7 @@
     "crowi-pluginkit": "^1.1.0",
     "csrf": "~3.0.3",
     "css-loader": "^0.28.0",
+    "csv-to-markdown-table": "^0.4.0",
     "date-fns": "^1.29.0",
     "debug": "^3.1.0",
     "diff": "^3.3.0",
@@ -82,17 +83,22 @@
     "express-session": "~1.15.0",
     "express-webpack-assets": "^0.1.0",
     "file-loader": "^1.1.0",
-    "get-line-from-pos": "^1.0.0",
     "googleapis": "^26.0.0",
     "graceful-fs": "^4.1.11",
-    "highlight.js": "^9.10.0",
     "i18next": "^10.0.1",
     "i18next-express-middleware": "^1.0.5",
     "i18next-node-fs-backend": "^1.0.0",
     "i18next-sprintf-postprocessor": "^0.2.2",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
-    "marked": "^0.3.12",
+    "markdown-it": "^8.4.0",
+    "markdown-it-emoji": "^1.4.0",
+    "markdown-it-footnote": "^3.0.1",
+    "markdown-it-mathjax": "^2.0.0",
+    "markdown-it-named-headers": "^0.0.4",
+    "markdown-it-plantuml": "^0.3.1",
+    "markdown-it-task-lists": "^2.1.0",
+    "markdown-it-toc-and-anchor-with-slugid": "^1.1.2",
     "md5": "^2.2.1",
     "method-override": "^2.3.10",
     "mkdirp": "~0.5.1",
@@ -109,7 +115,6 @@
     "passport-ldapauth": "^2.0.0",
     "passport-local": "^1.0.0",
     "pino-clf": "^1.0.2",
-    "plantuml-encoder": "^1.2.4",
     "react": "^16.0.0",
     "react-bootstrap": "^0.32.0",
     "react-bootstrap-typeahead": "^2.0.2",
@@ -128,6 +133,8 @@
     "throttle-debounce": "^1.0.1",
     "toastr": "^2.1.2",
     "uglifycss": "^0.0.27",
+    "url-join": "^4.0.0",
+    "uslug": "^1.0.4",
     "webpack": "^3.1.0",
     "webpack-bundle-analyzer": "^2.9.0",
     "webpack-merge": "~4.1.0",

+ 10 - 3
resource/css/_comment_crowi-plus.scss

@@ -90,19 +90,26 @@
   .comment-form {
     position: relative;
     margin-top: 2em;
-    // ユーザーアイコン
+    // user icon
     .picture.picture-rounded {
       @extend %picture-rounded;
     }
 
-    // コメントフォームセクション
+    // seciton
     .comment-form-main {
       @extend %comment-section;
     }
 
-    // コメント入力フォーム
+    // textarea
     .comment-write {
       margin-bottom: 0.5em;
     }
+    .comment-form-comment {
+      height: 80px;
+      &:focus, &:not(:invalid) {
+        transition: height 0.2s ease-out;
+        height: 180px;
+      }
+    }
   }
 }

+ 1 - 1
resource/css/_form.scss

@@ -214,7 +214,7 @@
       margin-right: 0.5em;
     }
 
-    .btn.btn-style-active-line {
+    .btn-style-active-line, .btn-render-mathjax-in-realtime {
       &:hover:not(.active), &:focus:not(.active) {
         background-color: inherit;
       }

+ 2 - 2
resource/css/_user_crowi-plus.scss

@@ -7,12 +7,12 @@
 
     .revision-toc {
       .revision-toc-content {
-        margin-top: 25px;
+        margin-top: 0;
         background: none;
       }
 
       &.affix {
-        top: 10px;
+        top: 0;
       }
     }
 

+ 23 - 70
resource/css/_wiki.scss

@@ -34,7 +34,7 @@ div.body {
     background: #fcfcfc;
     padding: 10px;
 
-    > ul {
+    > ul > li { // first level of li
       margin: 4px 4px 4px 15px;
       padding: 5px;
     }
@@ -135,14 +135,18 @@ div.body {
 
   img {
     margin: 5px 0;
+/* ensure to disable in crowi-plus system
     box-shadow: 0 0 5px 0px rgba(0,0,0,.2);
     border: solid 1px #ccc;
+*/
     max-width: 100%;
   }
+/* ensure to disable in crowi-plus system
   .noborder img, .img.noborder {
     box-shadow: none;
     border: none;
   }
+*/
 
   img.emojione {
     margin-top: -0.3em !important;
@@ -165,8 +169,21 @@ div.body {
     }
   }
 
-/* ensure to disable in crowi-plus
-  .wiki-code {
+  // borrowed from https://www.npmjs.com/package/github-markdown-css
+  .contains-task-list {
+    .task-list-item {
+      list-style-type: none;
+    }
+    .task-list-item+.task-list-item {
+      margin-top: 3px;
+    }
+    .task-list-item input {
+      margin: 0 0.2em 0.25em -1.6em;
+      vertical-align: middle;
+    }
+  }
+
+  pre.hljs {
     position: relative;
 
     cite {
@@ -176,11 +193,11 @@ div.body {
       padding: 0 4px;
       background: #ccc;
       color: #333;
-      font-size: .8em;
-
+      font-style: normal;
+      font-weight: bold;
+      opacity: 0.6;
     }
   };
-*/
 
   p code {  // only inline code blocks
     font-family: $font-family-monospace-not-strictly;
@@ -208,70 +225,6 @@ div.body {
     border-radius: 3px;
   }
 
-  // {{{ table (copied from bootstrap .table
-  table {
-    width: 100%;
-    margin-bottom: $line-height-computed;
-    // Cells
-    > thead,
-    > tbody,
-    > tfoot {
-      > tr {
-        > th,
-        > td {
-          padding: $table-cell-padding;
-          line-height: $line-height-base;
-          vertical-align: top;
-          border-top: 1px solid $table-border-color;
-        }
-      }
-    }
-    // Bottom align for column headings
-    > thead > tr > th {
-      vertical-align: bottom;
-      border-bottom: 2px solid $table-border-color;
-    }
-    // Remove top border from thead by default
-    > caption + thead,
-    > colgroup + thead,
-    > thead:first-child {
-      > tr:first-child {
-        > th,
-        > td {
-          border-top: 0;
-        }
-      }
-    }
-    // Account for multiple tbody instances
-    > tbody + tbody {
-      border-top: 2px solid $table-border-color;
-    }
-
-    // Nesting
-    table {
-      background-color: $body-bg;
-    }
-
-    // .table-bordered
-    border: 1px solid $table-border-color;
-    > thead,
-    > tbody,
-    > tfoot {
-      > tr {
-        > th,
-        > td {
-          border: 1px solid $table-border-color;
-        }
-      }
-    }
-    > thead > tr {
-      > th,
-      > td {
-        border-bottom-width: 2px;
-      }
-    }
-  }
-  // }}}
 }
 
 

+ 1 - 1
resource/css/_wiki_crowi-plus.scss

@@ -15,7 +15,7 @@
 
     // affix
     &.affix {
-      top: 30px;
+      top: 0;
     }
   }
 }

+ 1 - 1
resource/css/crowi-reveal.scss

@@ -126,4 +126,4 @@
     // }}}
 
   }
-}
+}

+ 51 - 21
resource/js/app.js

@@ -2,12 +2,15 @@ import React from 'react';
 import ReactDOM from 'react-dom';
 
 import Crowi from './util/Crowi';
-import CrowiRenderer from './util/CrowiRenderer';
+// import CrowiRenderer from './util/CrowiRenderer';
+import GrowiRenderer from './util/GrowiRenderer';
 
 import HeaderSearchBox  from './components/HeaderSearchBox';
 import SearchPage       from './components/SearchPage';
 import PageEditor       from './components/PageEditor';
-import EditorOptionsSelector from './components/PageEditor/EditorOptionsSelector';
+import OptionsSelector  from './components/PageEditor/OptionsSelector';
+import { EditorOptions, PreviewOptions } from './components/PageEditor/OptionsSelector';
+import Page             from './components/Page';
 import PageListSearch   from './components/PageListSearch';
 import PageHistory      from './components/PageHistory';
 import PageComments     from './components/PageComments';
@@ -36,6 +39,7 @@ let pageRevisionId = null;
 let pageRevisionCreatedAt = null;
 let pagePath;
 let pageContent = '';
+let markdown = '';
 if (mainContent !== null) {
   pageId = mainContent.getAttribute('data-page-id');
   pageRevisionId = mainContent.getAttribute('data-page-revision-id');
@@ -45,6 +49,7 @@ if (mainContent !== null) {
   if (rawText) {
     pageContent = rawText.innerHTML;
   }
+  markdown = entities.decodeHTML(pageContent);
 }
 const isLoggedin = document.querySelector('.main-container.nologin') == null;
 
@@ -59,7 +64,11 @@ if (isLoggedin) {
   crowi.fetchUsers();
 }
 
-const crowiRenderer = new CrowiRenderer(crowi);
+const crowiRenderer = new GrowiRenderer(crowi, null, {
+  mode: 'page',
+  isAutoSetup: false,                                     // manually setup because plugins may configure it
+  renderToc: crowi.getCrowiForJquery().renderTocContent,  // function for rendering Table Of Contents
+});
 window.crowiRenderer = crowiRenderer;
 
 // FIXME
@@ -69,10 +78,8 @@ if (isEnabledPlugins) {
   crowiPlugin.installAll(crowi, crowiRenderer);
 }
 
-// for PageEditor
-const onSaveSuccess = function(page) {
-  crowi.getCrowiForJquery().updateCurrentRevision(page.revision._id);
-}
+// configure renderer
+crowiRenderer.setup(crowi.config);
 
 /**
  * define components
@@ -81,10 +88,8 @@ const onSaveSuccess = function(page) {
  */
 const componentMappings = {
   'search-top': <HeaderSearchBox crowi={crowi} />,
-  'search-page': <SearchPage crowi={crowi} />,
+  'search-page': <SearchPage crowi={crowi} crowiRenderer={crowiRenderer} />,
   'page-list-search': <PageListSearch crowi={crowi} />,
-  'page-comments-list': <PageComments pageId={pageId} revisionId={pageRevisionId} revisionCreatedAt= {pageRevisionCreatedAt} crowi={crowi} />,
-  'page-attachment': <PageAttachment pageId={pageId} pageContent={pageContent} crowi={crowi} />,
 
   //'revision-history': <PageHistory pageId={pageId} />,
   'seen-user-list': <SeenUserList pageId={pageId} crowi={crowi} />,
@@ -93,13 +98,19 @@ const componentMappings = {
   'page-name-inputter': <NewPageNameInputter crowi={crowi} parentPageName={pagePath} />,
 
 };
-// additional definitions if pagePath exists
+// additional definitions if data exists
+if (pageId) {
+  componentMappings['page-comments-list'] = <PageComments pageId={pageId} revisionId={pageRevisionId} revisionCreatedAt={pageRevisionCreatedAt} crowi={crowi} />;
+  componentMappings['page-attachment'] = <PageAttachment pageId={pageId} pageContent={pageContent} crowi={crowi} />;
+}
 if (pagePath) {
+  componentMappings['page'] = <Page crowi={crowi} crowiRenderer={crowiRenderer} markdown={markdown} pagePath={pagePath} showHeadEditButton={true} />;
   componentMappings['revision-path'] = <RevisionPath pagePath={pagePath} crowi={crowi} />;
   componentMappings['revision-url'] = <RevisionUrl pageId={pageId} pagePath={pagePath} />;
 }
 
 let componentInstances = {};
+
 Object.keys(componentMappings).forEach((key) => {
   const elem = document.getElementById(key);
   if (elem) {
@@ -117,28 +128,47 @@ if (elem) {
  * PageEditor
  */
 let pageEditor = null;
+const editorOptions = new EditorOptions(crowi.editorOptions);
+const previewOptions = new PreviewOptions(crowi.previewOptions);
 // render PageEditor
 const pageEditorElem = document.getElementById('page-editor');
 if (pageEditorElem) {
+  // create onSave event handler
+  const onSaveSuccess = function(page) {
+    // modify the revision id value to pass checking id when updating
+    crowi.getCrowiForJquery().updateCurrentRevision(page.revision._id);
+    // re-render Page component if exists
+    if (componentInstances.page != null) {
+      componentInstances.page.setMarkdown(page.revision.body);
+    }
+  }
+
   pageEditor = ReactDOM.render(
-    <PageEditor crowi={crowi} pageId={pageId} revisionId={pageRevisionId} pagePath={pagePath}
-        markdown={entities.decodeHTML(pageContent)} editorOptions={crowi.editorOptions}
+    <PageEditor crowi={crowi} crowiRenderer={crowiRenderer}
+        pageId={pageId} revisionId={pageRevisionId} pagePath={pagePath}
+        markdown={markdown}
+        editorOptions={editorOptions} previewOptions={previewOptions}
         onSaveSuccess={onSaveSuccess} />,
     pageEditorElem
   );
   // set refs for pageEditor
   crowi.setPageEditor(pageEditor);
 }
-// render EditorOptionsSelector
-const editorOptionSelectorElem = document.getElementById('page-editor-options-selector');
-if (editorOptionSelectorElem) {
+// render OptionsSelector
+const pageEditorOptionsSelectorElem = document.getElementById('page-editor-options-selector');
+if (pageEditorOptionsSelectorElem) {
   ReactDOM.render(
-    <EditorOptionsSelector options={crowi.editorOptions}
-        onChange={(opts) => { // set onChange event handler
-          pageEditor.setEditorOptions(opts);
-          crowi.saveEditorOptions(opts);
+    <OptionsSelector crowi={crowi}
+        editorOptions={editorOptions} previewOptions={previewOptions}
+        onChange={(newEditorOptions, newPreviewOptions) => { // set onChange event handler
+          // set options
+          pageEditor.setEditorOptions(newEditorOptions);
+          pageEditor.setPreviewOptions(newPreviewOptions);
+          // save
+          crowi.saveEditorOptions(newEditorOptions);
+          crowi.savePreviewOptions(newPreviewOptions);
         }} />,
-    editorOptionSelectorElem
+    pageEditorOptionsSelectorElem
   );
 }
 

+ 131 - 0
resource/js/components/Page.js

@@ -0,0 +1,131 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import RevisionBody from './Page/RevisionBody';
+
+export default class Page extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      html: '',
+    };
+
+    this.appendEditSectionButtons = this.appendEditSectionButtons.bind(this);
+    this.renderHtml = this.renderHtml.bind(this);
+    this.getHighlightedBody = this.getHighlightedBody.bind(this);
+  }
+
+  componentWillMount() {
+    this.renderHtml(this.props.markdown, this.props.highlightKeywords);
+  }
+
+  componentDidUpdate() {
+    this.appendEditSectionButtons();
+  }
+
+  componentWillReceiveProps(nextProps) {
+    this.renderHtml(nextProps.markdown, nextProps.highlightKeywords);
+  }
+
+  setMarkdown(markdown) {
+    this.setState({ markdown });
+    this.renderHtml(markdown, this.props.highlightKeywords);
+  }
+
+  /**
+   * Add edit section buttons to headers
+   * This invoke `appendEditSectionButtons` method of `legacy/crowi.js`
+   *
+   * TODO: transplant `appendEditSectionButtons` to this class in the future
+   */
+  appendEditSectionButtons(parentElement) {
+    if (this.props.showHeadEditButton) {
+      const crowiForJquery = this.props.crowi.getCrowiForJquery();
+      crowiForJquery.appendEditSectionButtons(this.revisionBodyElement);
+    }
+  }
+
+  /**
+   * transplanted from legacy code -- Yuki Takei
+   * @param {string} body html strings
+   * @param {string} keywords
+   */
+  getHighlightedBody(body, keywords) {
+    let returnBody = body;
+
+    keywords.replace(/"/g, '').split(' ').forEach((keyword) => {
+      if (keyword === '') {
+        return;
+      }
+      const k = keyword
+            .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+            .replace(/(^"|"$)/g, ''); // for phrase (quoted) keyword
+      const keywordExp = new RegExp(`(${k}(?!(.*?")))`, 'ig');
+      returnBody = returnBody.replace(keywordExp, '<em class="highlighted">$&</em>');
+    });
+
+    return returnBody;
+  }
+
+  renderHtml(markdown, highlightKeywords) {
+    var context = {
+      markdown,
+      dom: this.revisionBodyElement,
+      currentPagePath: this.props.pagePath,
+    };
+
+    const crowiRenderer = this.props.crowiRenderer;
+    const interceptorManager = this.props.crowi.interceptorManager;
+    interceptorManager.process('preRender', context)
+      .then(() => interceptorManager.process('prePreProcess', context))
+      .then(() => {
+        context.markdown = crowiRenderer.preProcess(context.markdown);
+      })
+      .then(() => interceptorManager.process('postPreProcess', context))
+      .then(() => {
+        var parsedHTML = crowiRenderer.process(context.markdown);
+        context['parsedHTML'] = parsedHTML;
+      })
+      .then(() => interceptorManager.process('prePostProcess', context))
+      .then(() => {
+        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML, context.dom);
+
+        // highlight
+        if (highlightKeywords != null) {
+          context.parsedHTML = this.getHighlightedBody(context.parsedHTML, highlightKeywords);
+        }
+      })
+      .then(() => interceptorManager.process('postPostProcess', context))
+      .then(() => interceptorManager.process('preRenderHtml', context))
+      .then(() => {
+        this.setState({ html: context.parsedHTML });
+      })
+      // process interceptors for post rendering
+      .then(() => interceptorManager.process('postRenderHtml', context));
+
+  }
+
+  render() {
+    const config = this.props.crowi.getConfig();
+    const isMathJaxEnabled = !!config.env.MATHJAX;
+
+    return (
+      <RevisionBody html={this.state.html}
+          inputRef={el => this.revisionBodyElement = el}
+          isMathJaxEnabled={isMathJaxEnabled}
+          renderMathJaxOnInit={true}
+      />
+    )
+  }
+}
+
+Page.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  crowiRenderer: PropTypes.object.isRequired,
+  markdown: PropTypes.string.isRequired,
+  pagePath: PropTypes.string.isRequired,
+  showHeadEditButton: PropTypes.bool,
+  highlightKeywords: PropTypes.string,
+};

+ 0 - 70
resource/js/components/Page/PageBody.js

@@ -1,70 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-export default class PageBody extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.crowiRenderer = window.crowiRenderer; // FIXME
-    this.getMarkupHTML = this.getMarkupHTML.bind(this);
-    this.getHighlightBody = this.getHighlightBody.bind(this);
-  }
-
-  getHighlightBody(body, keywords) {
-    let returnBody = body;
-
-    keywords.replace(/"/g, '').split(' ').forEach((keyword) => {
-      if (keyword === '') {
-        return;
-      }
-      const k = keyword
-            .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
-            .replace(/(^"|"$)/g, ''); // for phrase (quoted) keyword
-      const keywordExp = new RegExp(`(${k}(?!(.*?")))`, 'ig');
-      returnBody = returnBody.replace(keywordExp, '<em class="highlighted">$&</em>');
-    });
-
-    return returnBody;
-  }
-
-  getMarkupHTML() {
-    let body = this.props.pageBody;
-    if (body === '') {
-      body = this.props.page.revision.body;
-    }
-
-    body = this.crowiRenderer.render(body, undefined, this.props.rendererOptions);
-
-    if (this.props.highlightKeywords) {
-      body = this.getHighlightBody(body, this.props.highlightKeywords);
-    }
-
-    return { __html: body };
-  }
-
-  render() {
-    let parsedBody = this.getMarkupHTML();
-
-    return (
-      <div
-        className="content"
-        dangerouslySetInnerHTML={parsedBody}
-        />
-    );
-  }
-}
-
-PageBody.propTypes = {
-  page: PropTypes.object.isRequired,
-  highlightKeywords: PropTypes.string,
-  pageBody: PropTypes.string,
-  rendererOptions: PropTypes.object,
-};
-
-PageBody.defaultProps = {
-  page: {},
-  pageBody: '',
-  rendererOptions: {},
-};
-

+ 66 - 0
resource/js/components/Page/RevisionBody.js

@@ -0,0 +1,66 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { debounce } from 'throttle-debounce';
+
+export default class RevisionBody extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    // create debounced method for rendering MathJax
+    this.renderMathJaxWithDebounce = debounce(200, this.renderMathJax);
+  }
+
+  componentDidMount() {
+    const MathJax = window.MathJax;
+    if (MathJax != null && this.props.isMathJaxEnabled && this.props.renderMathJaxOnInit) {
+      this.renderMathJaxWithDebounce();
+    }
+  }
+
+  componentDidUpdate() {
+    const MathJax = window.MathJax;
+    if (MathJax != null && this.props.isMathJaxEnabled && this.props.renderMathJaxInRealtime) {
+      this.renderMathJaxWithDebounce();
+    }
+  }
+
+  componentWillReceiveProps(nextProps) {
+    const MathJax = window.MathJax;
+    if (MathJax != null && this.props.isMathJaxEnabled && this.props.renderMathJaxOnInit) {
+      this.renderMathJaxWithDebounce();
+    }
+  }
+
+  renderMathJax() {
+    const MathJax = window.MathJax;
+    MathJax.Hub.Queue(["Typeset", MathJax.Hub, this.element]);
+  }
+
+  generateInnerHtml(html) {
+    return {__html: html};
+  }
+
+  render() {
+    return (
+      <div
+        ref={(elm) => {
+          this.element = elm;
+          if (this.props.inputRef != null) {
+            this.props.inputRef(elm);
+          }
+        }}
+        className="wiki" dangerouslySetInnerHTML={this.generateInnerHtml(this.props.html)}>
+      </div>
+    )
+  }
+}
+
+RevisionBody.propTypes = {
+  html: PropTypes.string,
+  inputRef: PropTypes.func,  // for getting div element
+  isMathJaxEnabled: PropTypes.bool,
+  renderMathJaxOnInit: PropTypes.bool,
+  renderMathJaxInRealtime: PropTypes.bool,
+};

+ 136 - 37
resource/js/components/PageEditor.js

@@ -4,8 +4,12 @@ import PropTypes from 'prop-types';
 import * as toastr from 'toastr';
 import { throttle, debounce } from 'throttle-debounce';
 
+import GrowiRenderer from '../util/GrowiRenderer';
+
+import { EditorOptions, PreviewOptions } from './PageEditor/OptionsSelector';
 import Editor from './PageEditor/Editor';
 import Preview from './PageEditor/Preview';
+import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
 
 export default class PageEditor extends React.Component {
 
@@ -15,31 +19,44 @@ export default class PageEditor extends React.Component {
     const config = this.props.crowi.getConfig();
     const isUploadable = config.upload.image || config.upload.file;
     const isUploadableFile = config.upload.file;
+    const isMathJaxEnabled = !!config.env.MATHJAX;
 
     this.state = {
       revisionId: this.props.revisionId,
       markdown: this.props.markdown,
       isUploadable,
       isUploadableFile,
+      isMathJaxEnabled,
       editorOptions: this.props.editorOptions,
+      previewOptions: this.props.previewOptions,
     };
 
+    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiRenderer, {mode: 'editor'});
+
     this.setCaretLine = this.setCaretLine.bind(this);
     this.focusToEditor = this.focusToEditor.bind(this);
     this.onMarkdownChanged = this.onMarkdownChanged.bind(this);
     this.onSave = this.onSave.bind(this);
     this.onUpload = this.onUpload.bind(this);
     this.onEditorScroll = this.onEditorScroll.bind(this);
-    this.getMaxScrollTop = this.getMaxScrollTop.bind(this);
-    this.getScrollTop = this.getScrollTop.bind(this);
+    this.onEditorScrollCursorIntoView = this.onEditorScrollCursorIntoView.bind(this);
+    this.onPreviewScroll = this.onPreviewScroll.bind(this);
     this.saveDraft = this.saveDraft.bind(this);
     this.clearDraft = this.clearDraft.bind(this);
     this.pageSavedHandler = this.pageSavedHandler.bind(this);
     this.apiErrorHandler = this.apiErrorHandler.bind(this);
 
+    // for scrolling
+    this.lastScrolledDateWithCursor = null;
+    this.isOriginOfScrollSyncEditor = false;
+    this.isOriginOfScrollSyncEditor = false;
+
     // create throttled function
+    this.scrollPreviewByEditorLineWithThrottle = throttle(20, this.scrollPreviewByEditorLine);
+    this.scrollPreviewByCursorMovingWithThrottle = throttle(20, this.scrollPreviewByCursorMoving);
+    this.scrollEditorByPreviewScrollWithThrottle = throttle(20, this.scrollEditorByPreviewScroll);
     this.renderWithDebounce = debounce(50, throttle(100, this.renderPreview));
-    this.saveDraftWithDebounce = debounce(300, this.saveDraft);
+    this.saveDraftWithDebounce = debounce(800, this.saveDraft);
   }
 
   componentWillMount() {
@@ -59,6 +76,7 @@ export default class PageEditor extends React.Component {
    */
   setCaretLine(line) {
     this.refs.editor.setCaretLine(line);
+    scrollSyncHelper.scrollPreview(this.previewElement, line);
   }
 
   /**
@@ -69,6 +87,14 @@ export default class PageEditor extends React.Component {
     this.setState({ editorOptions });
   }
 
+  /**
+   * set options (used from the outside)
+   * @param {object} previewOptions
+   */
+  setPreviewOptions(previewOptions) {
+    this.setState({ previewOptions });
+  }
+
   /**
    * the change event handler for `markdown` state
    * @param {string} value
@@ -165,32 +191,99 @@ export default class PageEditor extends React.Component {
   /**
    * the scroll event handler from codemirror
    * @param {any} data {left, top, width, height, clientWidth, clientHeight} object that represents the current scroll position, the size of the scrollable area, and the size of the visible area (minus scrollbars).
-   *    see https://codemirror.net/doc/manual.html#events
+   *                    And data.line is also available that is added by Editor component
+   * @see https://codemirror.net/doc/manual.html#events
    */
   onEditorScroll(data) {
-    const rate = data.top / (data.height - data.clientHeight)
-    const top = this.getScrollTop(this.previewElement, rate);
+    // prevent scrolling
+    //   if the elapsed time from last scroll with cursor is shorter than 40ms
+    const now = new Date();
+    if (now - this.lastScrolledDateWithCursor < 40) {
+      return;
+    }
 
-    this.previewElement.scrollTop = top;
+    this.scrollPreviewByEditorLineWithThrottle(data.line);
   }
+
   /**
-   * transplanted from crowi-form.js -- 2018.01.21 Yuki Takei
-   * @param {*} dom
+   * the scroll event handler from codemirror
+   * @param {number} line
+   * @see https://codemirror.net/doc/manual.html#events
    */
-  getMaxScrollTop(dom) {
-    var rect = dom.getBoundingClientRect();
-    return dom.scrollHeight - rect.height;
+  onEditorScrollCursorIntoView(line) {
+    // record date
+    this.lastScrolledDateWithCursor = new Date();
+    this.scrollPreviewByCursorMovingWithThrottle(line);
+  }
+
+  /**
+   * scroll Preview element by scroll event
+   * @param {number} line
+   */
+  scrollPreviewByEditorLine(line) {
+    if (this.previewElement == null) {
+      return;
+    }
+
+    // prevent circular invocation
+    if (this.isOriginOfScrollSyncPreview) {
+      this.isOriginOfScrollSyncPreview = false; // turn off the flag
+      return;
+    }
+
+    // turn on the flag
+    this.isOriginOfScrollSyncEditor = true;
+    scrollSyncHelper.scrollPreview(this.previewElement, line);
   };
+
   /**
-   * transplanted from crowi-form.js -- 2018.01.21 Yuki Takei
-   * @param {*} dom
+   * scroll Preview element by cursor moving
+   * @param {number} line
    */
-  getScrollTop(dom, rate) {
-    var maxScrollTop = this.getMaxScrollTop(dom);
-    var top = maxScrollTop * rate;
-    return top;
+  scrollPreviewByCursorMoving(line) {
+    if (this.previewElement == null) {
+      return;
+    }
+
+    // prevent circular invocation
+    if (this.isOriginOfScrollSyncPreview) {
+      this.isOriginOfScrollSyncPreview = false; // turn off the flag
+      return;
+    }
+
+    // turn on the flag
+    this.isOriginOfScrollSyncEditor = true;
+    scrollSyncHelper.scrollPreviewToRevealOverflowing(this.previewElement, line);
   };
 
+  /**
+   * the scroll event handler from Preview component
+   * @param {number} offset
+   */
+  onPreviewScroll(offset) {
+    this.scrollEditorByPreviewScrollWithThrottle(offset);
+  }
+
+  /**
+   * scroll Editor component by scroll event of Preview component
+   * @param {number} offset
+   */
+  scrollEditorByPreviewScroll(offset) {
+    if (this.previewElement == null) {
+      return;
+    }
+
+    // prevent circular invocation
+    if (this.isOriginOfScrollSyncEditor) {
+      this.isOriginOfScrollSyncEditor = false;  // turn off the flag
+      return;
+    }
+
+    // turn on the flag
+    this.isOriginOfScrollSyncPreview = true;
+    scrollSyncHelper.scrollEditor(this.refs.editor, this.previewElement, offset);
+  }
+
   /*
    * methods for draft
    */
@@ -244,14 +337,6 @@ export default class PageEditor extends React.Component {
 
     this.setState({ markdown: value });
 
-    // generate options obj
-    const rendererOptions = {
-      // see: https://www.npmjs.com/package/marked
-      marked: {
-        breaks: config.isEnabledLineBreaks,
-      }
-    };
-
     // render html
     var context = {
       markdown: this.state.markdown,
@@ -259,26 +344,31 @@ export default class PageEditor extends React.Component {
       currentPagePath: decodeURIComponent(location.pathname)
     };
 
-    this.props.crowi.interceptorManager.process('preRenderPreview', context)
-      .then(() => crowi.interceptorManager.process('prePreProcess', context))
+    const growiRenderer = this.growiRenderer;
+    const interceptorManager = this.props.crowi.interceptorManager;
+    interceptorManager.process('preRenderPreview', context)
+      .then(() => interceptorManager.process('prePreProcess', context))
       .then(() => {
-        context.markdown = crowiRenderer.preProcess(context.markdown, context.dom);
+        context.markdown = growiRenderer.preProcess(context.markdown);
       })
-      .then(() => crowi.interceptorManager.process('postPreProcess', context))
+      .then(() => interceptorManager.process('postPreProcess', context))
       .then(() => {
-        var parsedHTML = crowiRenderer.render(context.markdown, context.dom, rendererOptions);
+        var parsedHTML = growiRenderer.process(context.markdown);
         context['parsedHTML'] = parsedHTML;
       })
-      .then(() => crowi.interceptorManager.process('postRenderPreview', context))
-      .then(() => crowi.interceptorManager.process('preRenderPreviewHtml', context))
+      .then(() => interceptorManager.process('prePostProcess', context))
+      .then(() => {
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context.dom);
+      })
+      .then(() => interceptorManager.process('postPostProcess', context))
+      .then(() => interceptorManager.process('preRenderPreviewHtml', context))
       .then(() => {
         this.setState({ html: context.parsedHTML });
-
         // set html to the hidden input (for submitting to save)
         $('#form-body').val(this.state.markdown);
       })
       // process interceptors for post rendering
-      .then(() => crowi.interceptorManager.process('postRenderPreviewHtml', context));
+      .then(() => interceptorManager.process('postRenderPreviewHtml', context));
 
   }
 
@@ -291,13 +381,20 @@ export default class PageEditor extends React.Component {
               isUploadable={this.state.isUploadable}
               isUploadableFile={this.state.isUploadableFile}
               onScroll={this.onEditorScroll}
+              onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
               onChange={this.onMarkdownChanged}
               onSave={this.onSave}
               onUpload={this.onUpload}
           />
         </div>
         <div className="col-md-6 hidden-sm hidden-xs page-editor-preview-container">
-          <Preview html={this.state.html} inputRef={el => this.previewElement = el} />
+          <Preview html={this.state.html}
+              inputRef={el => this.previewElement = el}
+              isMathJaxEnabled={this.state.isMathJaxEnabled}
+              renderMathJaxOnInit={false}
+              previewOptions={this.state.previewOptions}
+              onScroll={this.onPreviewScroll}
+          />
         </div>
       </div>
     )
@@ -306,10 +403,12 @@ export default class PageEditor extends React.Component {
 
 PageEditor.propTypes = {
   crowi: PropTypes.object.isRequired,
+  crowiRenderer: PropTypes.object.isRequired,
   markdown: PropTypes.string.isRequired,
   pageId: PropTypes.string,
   revisionId: PropTypes.string,
   pagePath: PropTypes.string,
   onSaveSuccess: PropTypes.func,
-  editorOptions: PropTypes.object,
+  editorOptions: PropTypes.instanceOf(EditorOptions),
+  previewOptions: PropTypes.instanceOf(PreviewOptions),
 };

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

@@ -54,9 +54,11 @@ export default class Editor extends React.Component {
 
     this.getCodeMirror = this.getCodeMirror.bind(this);
     this.setCaretLine = this.setCaretLine.bind(this);
+    this.setScrollTopByLine = this.setScrollTopByLine.bind(this);
     this.forceToFocus = this.forceToFocus.bind(this);
     this.dispatchSave = this.dispatchSave.bind(this);
 
+    this.onScrollCursorIntoView = this.onScrollCursorIntoView.bind(this);
     this.onPaste = this.onPaste.bind(this);
 
     this.onDragEnterForCM = this.onDragEnterForCM.bind(this);
@@ -95,15 +97,30 @@ export default class Editor extends React.Component {
    * @param {string} number
    */
   setCaretLine(line) {
-    const editor = this.getCodeMirror();
+    if (isNaN(line)) {
+      return;
+    }
 
-    // scroll to the bottom for a moment
-    const lastLine = editor.getDoc().lastLine();
-    editor.scrollIntoView(lastLine);
+    const editor = this.getCodeMirror();
+    const linePosition = Math.max(0, line);
 
-    const linePosition = Math.max(0, line - 1);
-    editor.scrollIntoView(linePosition);
     editor.setCursor({line: linePosition});   // leave 'ch' field as null/undefined to indicate the end of line
+    this.setScrollTopByLine(linePosition);
+  }
+
+  /**
+   * scroll
+   * @param {number} line
+   */
+  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);
   }
 
   /**
@@ -143,6 +160,13 @@ export default class Editor extends React.Component {
     }
   }
 
+  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
@@ -296,6 +320,7 @@ export default class Editor extends React.Component {
             editorDidMount={(editor) => {
               // add event handlers
               editor.on('paste', this.onPaste);
+              editor.on('scrollCursorIntoView', this.onScrollCursorIntoView);
             }}
             value={this.state.value}
             options={{
@@ -327,6 +352,9 @@ export default class Editor extends React.Component {
             }}
             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);
               }
             }}
@@ -363,6 +391,7 @@ Editor.propTypes = {
   isUploadableFile: PropTypes.bool,
   onChange: PropTypes.func,
   onScroll: PropTypes.func,
+  onScrollCursorIntoView: PropTypes.func,
   onSave: PropTypes.func,
   onUpload: PropTypes.func,
 };

+ 0 - 99
resource/js/components/PageEditor/EditorOptionsSelector.js

@@ -1,99 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-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';
-
-export default class EditorOptionsSelector extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      options: this.props.options,
-    }
-
-    this.availableThemes = [
-      'elegant', 'neo', 'mdn-like', 'material', 'monokai', 'twilight'
-    ]
-
-    this.onChangeTheme = this.onChangeTheme.bind(this);
-    this.onClickStyleActiveLine = this.onClickStyleActiveLine.bind(this);
-  }
-
-  componentDidMount() {
-    this.init();
-  }
-
-  init() {
-    this.themeSelectorInputEl.value = this.state.options.theme || this.availableThemes[0];
-  }
-
-  onChangeTheme() {
-    const newValue = this.themeSelectorInputEl.value;
-    const newOpts = Object.assign(this.state.options, {theme: newValue});
-    this.setState({options: newOpts});
-
-    // dispatch event
-    this.dispatchOnChange();
-  }
-
-  onClickStyleActiveLine(event) {
-    const newValue = !this.state.options.styleActiveLine;
-    console.log(newValue);
-    const newOpts = Object.assign(this.state.options, {styleActiveLine: newValue});
-    this.setState({options: newOpts});
-
-    // dispatch event
-    this.dispatchOnChange();
-  }
-
-  dispatchOnChange() {
-    if (this.props.onChange != null) {
-      this.props.onChange(this.state.options);
-    }
-  }
-
-  renderThemeSelector() {
-    const optionElems = this.availableThemes.map((theme) => {
-      return <option key={theme} value={theme}>{theme}</option>;
-    });
-
-    return (
-      <FormGroup controlId="formControlsSelect">
-        <ControlLabel>Theme:</ControlLabel>
-        <FormControl componentClass="select" placeholder="select"
-            onChange={this.onChangeTheme}
-            inputRef={ el => this.themeSelectorInputEl=el }>
-
-          {optionElems}
-
-        </FormControl>
-      </FormGroup>
-    )
-  }
-
-  renderStyleActiveLineSelector() {
-    const bool = this.state.options.styleActiveLine || false;
-    return (
-      <FormGroup controlId="formControlsSelect">
-        <Button active={bool} className="btn-style-active-line"
-            onClick={this.onClickStyleActiveLine}
-            ref="styleActiveLineButton">
-          Active Line
-        </Button>
-      </FormGroup>
-    )
-  }
-
-  render() {
-    return <span>{this.renderThemeSelector()} {this.renderStyleActiveLineSelector()}</span>
-  }
-}
-
-EditorOptionsSelector.propTypes = {
-  options: PropTypes.object,
-  onChange: PropTypes.func,
-};

+ 12 - 21
resource/js/components/PageEditor/EmojiAutoCompleteHelper.js

@@ -1,33 +1,23 @@
-import axios from 'axios';
+import emojiStrategy from '../../util/emojione/emoji_strategy_shrinked.json';
 
 class EmojiAutoCompleteHelper {
 
   constructor() {
-    this.emojiStrategy = {};
     this.emojiShortnameImageMap = {}
 
-    this.initEmojiImageMap()
-      .then(() => {
-        Object.freeze(this);  // freeze after initializing data
-      })
-
     this.initEmojiImageMap = this.initEmojiImageMap.bind(this);
     this.showHint = this.showHint.bind(this);
+
+    this.initEmojiImageMap()
   }
 
   initEmojiImageMap() {
-    const emojiStrategyUrl = 'https://cdn.jsdelivr.net/npm/emojione@3.1.2/emoji_strategy.json';
-
-    return axios.get(emojiStrategyUrl)
-      .then((res) => {
-        this.emojiStrategy = res.data;
-        for (let unicode in this.emojiStrategy) {
-          const data = this.emojiStrategy[unicode];
-          const shortname = data.shortname;
-          // add image tag
-          this.emojiShortnameImageMap[shortname] = emojione.shortnameToImage(shortname);
-        }
-      });
+    for (let unicode in emojiStrategy) {
+      const data = emojiStrategy[unicode];
+      const shortname = data.shortname;
+      // add image tag
+      this.emojiShortnameImageMap[shortname] = emojione.shortnameToImage(shortname);
+    }
   }
 
   /**
@@ -106,8 +96,8 @@ class EmojiAutoCompleteHelper {
     const countLen4 = () => { countLen3() + results4.length; }
     // TODO performance tune
     // when total length of all results is less than `maxLength`
-    for (let unicode in this.emojiStrategy) {
-      const data = this.emojiStrategy[unicode];
+    for (let unicode in emojiStrategy) {
+      const data = emojiStrategy[unicode];
 
       if (maxLength <= countLen1()) { break; }
       // prefix match to shortname
@@ -144,4 +134,5 @@ class EmojiAutoCompleteHelper {
 
 // singleton pattern
 const instance = new EmojiAutoCompleteHelper();
+Object.freeze(this);
 export default instance;

+ 161 - 0
resource/js/components/PageEditor/OptionsSelector.js

@@ -0,0 +1,161 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+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 OverlayTrigger  from 'react-bootstrap/es/OverlayTrigger';
+import Tooltip from 'react-bootstrap/es/Tooltip';
+
+export default class OptionsSelector extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    const config = this.props.crowi.getConfig();
+    const isMathJaxEnabled = !!config.env.MATHJAX;
+
+    this.state = {
+      editorOptions: this.props.editorOptions || new EditorOptions(),
+      previewOptions: this.props.previewOptions || new PreviewOptions(),
+      isMathJaxEnabled,
+    }
+
+    this.availableThemes = [
+      'elegant', 'neo', 'mdn-like', 'material', 'monokai', 'twilight'
+    ]
+
+    this.onChangeTheme = this.onChangeTheme.bind(this);
+    this.onClickStyleActiveLine = this.onClickStyleActiveLine.bind(this);
+    this.onClickRenderMathJaxInRealtime = this.onClickRenderMathJaxInRealtime.bind(this);
+  }
+
+  componentDidMount() {
+    this.init();
+  }
+
+  init() {
+    this.themeSelectorInputEl.value = this.state.editorOptions.theme;
+  }
+
+  onChangeTheme() {
+    const newValue = this.themeSelectorInputEl.value;
+    const newOpts = Object.assign(this.state.editorOptions, {theme: newValue});
+    this.setState({editorOptions: newOpts});
+
+    // dispatch event
+    this.dispatchOnChange();
+  }
+
+  onClickStyleActiveLine(event) {
+    const newValue = !this.state.editorOptions.styleActiveLine;
+    const newOpts = Object.assign(this.state.editorOptions, {styleActiveLine: newValue});
+    this.setState({editorOptions: newOpts});
+
+    // dispatch event
+    this.dispatchOnChange();
+  }
+
+  onClickRenderMathJaxInRealtime(event) {
+    const newValue = !this.state.previewOptions.renderMathJaxInRealtime;
+    const newOpts = Object.assign(this.state.previewOptions, {renderMathJaxInRealtime: newValue});
+    this.setState({previewOptions: newOpts});
+
+    // dispatch event
+    this.dispatchOnChange();
+  }
+
+  /**
+   * dispatch onChange event
+   */
+  dispatchOnChange() {
+    if (this.props.onChange != null) {
+      this.props.onChange(this.state.editorOptions, this.state.previewOptions);
+    }
+  }
+
+  renderThemeSelector() {
+    const optionElems = this.availableThemes.map((theme) => {
+      return <option key={theme} value={theme}>{theme}</option>;
+    });
+
+    return (
+      <FormGroup controlId="formControlsSelect">
+        <ControlLabel>Theme:</ControlLabel>
+        <FormControl componentClass="select" placeholder="select"
+            onChange={this.onChangeTheme}
+            inputRef={ el => this.themeSelectorInputEl=el }>
+
+          {optionElems}
+
+        </FormControl>
+      </FormGroup>
+    )
+  }
+
+  renderStyleActiveLineSelector() {
+    const bool = this.state.editorOptions.styleActiveLine;
+    return (
+      <FormGroup controlId="formControlsSelect">
+        <Button active={bool} className="btn-style-active-line"
+            onClick={this.onClickStyleActiveLine}>
+          Active Line
+        </Button>
+      </FormGroup>
+    )
+  }
+
+  renderRealtimeMathJaxSelector() {
+    if (!this.state.isMathJaxEnabled) {
+      return;
+    }
+
+    // tooltip
+    const tooltip = (
+      <Tooltip id="tooltip-realtime-mathjax-rendering">Realtime MathJax Rendering</Tooltip>
+    );
+
+    const isEnabled = this.state.isMathJaxEnabled;
+    const isActive = isEnabled && this.state.previewOptions.renderMathJaxInRealtime;
+    return (
+      <FormGroup controlId="formControlsSelect">
+        <OverlayTrigger placement="top" overlay={tooltip}>
+          <Button active={isActive} className="btn-render-mathjax-in-realtime"
+              onClick={this.onClickRenderMathJaxInRealtime}>
+            <i className="fa fa-superscript" aria-hidden="true"></i>
+          </Button>
+        </OverlayTrigger>
+      </FormGroup>
+    )
+  }
+
+  render() {
+    return <span>{this.renderThemeSelector()} {this.renderStyleActiveLineSelector()} {this.renderRealtimeMathJaxSelector()}</span>
+  }
+}
+
+export class EditorOptions {
+  constructor(props) {
+    this.theme = 'elegant';
+    this.styleActiveLine = false;
+
+    Object.assign(this, props);
+  }
+}
+
+export class PreviewOptions {
+  constructor(props) {
+    this.renderMathJaxInRealtime = false;
+
+    Object.assign(this, props);
+  }
+}
+
+OptionsSelector.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  editorOptions: PropTypes.instanceOf(EditorOptions),
+  previewOptions: PropTypes.instanceOf(PreviewOptions),
+  onChange: PropTypes.func,
+};

+ 28 - 7
resource/js/components/PageEditor/Preview.js

@@ -1,21 +1,38 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import RevisionBody from '../Page/RevisionBody';
+
+import { PreviewOptions } from './OptionsSelector';
+
+/**
+ * Wrapper component for Page/RevisionBody
+ */
 export default class Preview extends React.Component {
 
   constructor(props) {
     super(props);
   }
 
-  generateInnerHtml(html) {
-    return {__html: html};
-  }
-
   render() {
+    const renderMathJaxInRealtime = this.props.previewOptions.renderMathJaxInRealtime;
+
     return (
-      <div
-        ref={this.props.inputRef}
-        className="wiki page-editor-preview-body" dangerouslySetInnerHTML={this.generateInnerHtml(this.props.html)}>
+      <div className="page-editor-preview-body"
+          ref={(elm) => {
+            this.previewElement = elm;
+            this.props.inputRef(elm);
+          }}
+          onScroll={(event) => {
+            if (this.props.onScroll != null) {
+              this.props.onScroll(event.target.scrollTop);
+            }
+          }}>
+
+        <RevisionBody
+          {...this.props}
+          renderMathJaxInRealtime={renderMathJaxInRealtime}
+        />
       </div>
     )
   }
@@ -24,4 +41,8 @@ export default class Preview extends React.Component {
 Preview.propTypes = {
   html: PropTypes.string,
   inputRef: PropTypes.func.isRequired,  // for getting div element
+  isMathJaxEnabled: PropTypes.bool,
+  renderMathJaxOnInit: PropTypes.bool,
+  previewOptions: PropTypes.instanceOf(PreviewOptions),
+  onScroll: PropTypes.func,
 };

+ 195 - 0
resource/js/components/PageEditor/ScrollSyncHelper.js

@@ -0,0 +1,195 @@
+/**
+ * This class is copied from Microsoft/vscode repository
+ * @see https://github.com/Microsoft/vscode/blob/0532a3429a18688a0c086a4212e7e5b4888b2a48/extensions/markdown/media/main.js
+ */
+class ScrollSyncHelper {
+
+  /**
+	 * @typedef {{ element: Element, line: number }} CodeLineElement
+	 */
+
+  constructor() {
+  }
+
+  getCodeLineElements(parentElement) {
+    /** @type {CodeLineElement[]} */
+    let elements;
+    if (!elements) {
+      elements = Array.prototype.map.call(
+        parentElement.getElementsByClassName('code-line'),
+        element => {
+          const line = +element.getAttribute('data-line');
+          return { element, line }
+        })
+        .filter(x => !isNaN(x.line));
+    }
+    return elements;
+  }
+
+  /**
+	 * Find the html elements that map to a specific target line in the editor.
+	 *
+	 * If an exact match, returns a single element. If the line is between elements,
+	 * returns the element prior to and the element after the given line.
+	 *
+   * @param {Element} element
+	 * @param {number} targetLine
+	 *
+	 * @returns {{ previous: CodeLineElement, next?: CodeLineElement }}
+	 */
+	getElementsForSourceLine(element, targetLine) {
+		const lines = this.getCodeLineElements(element);
+		let previous = lines[0] || null;
+		for (const entry of lines) {
+			if (entry.line === targetLine) {
+				return { previous: entry, next: null };
+			} else if (entry.line > targetLine) {
+				return { previous, next: entry };
+			}
+			previous = entry;
+		}
+		return { previous };
+  }
+
+  /**
+	 * Find the html elements that are at a specific pixel offset on the page.
+	 *
+   * @param {Element} parentElement
+	 * @param {number} offset
+   *
+	 * @returns {{ previous: CodeLineElement, next?: CodeLineElement }}
+	 */
+	getLineElementsAtPageOffset(parentElement, offset) {
+		const lines = this.getCodeLineElements(parentElement);
+
+    const position = offset - parentElement.scrollTop + this.getParentElementOffset(parentElement);
+
+		let lo = -1;
+		let hi = lines.length - 1;
+		while (lo + 1 < hi) {
+			const mid = Math.floor((lo + hi) / 2);
+      const bounds = lines[mid].element.getBoundingClientRect();
+			if (bounds.top + bounds.height >= position) {
+				hi = mid;
+			} else {
+				lo = mid;
+			}
+    }
+
+		const hiElement = lines[hi];
+		if (hi >= 1 && hiElement.element.getBoundingClientRect().top > position) {
+			const loElement = lines[lo];
+			const bounds = loElement.element.getBoundingClientRect();
+      let previous = { element: loElement.element, line: loElement.line };
+      if (bounds.height > 0) {
+        previous.line += (position - bounds.top) / (bounds.height);
+      }
+      const next = { element: hiElement.element, line: hiElement.line, fractional: 0 };
+			return { previous, next };
+		}
+
+		const bounds = hiElement.element.getBoundingClientRect();
+    const previous = { element: hiElement.element, line: hiElement.line + (position - bounds.top) / (bounds.height) };
+		return { previous };
+  }
+
+  getEditorLineNumberForPageOffset(parentElement, offset) {
+    const { previous, next } = this.getLineElementsAtPageOffset(parentElement, offset);
+		if (previous) {
+			if (next) {
+        const betweenProgress = (offset - parentElement.scrollTop - previous.element.getBoundingClientRect().top) / (next.element.getBoundingClientRect().top - previous.element.getBoundingClientRect().top);
+				return previous.line + betweenProgress * (next.line - previous.line);
+			} else {
+				return previous.line;
+			}
+		}
+		return null;
+  }
+
+  /**
+   * return the sum of the offset position of parent element and paddingTop
+   * @param {Element} parentElement
+   */
+  getParentElementOffset(parentElement) {
+    const offsetY = parentElement.getBoundingClientRect().top;
+    // get paddingTop
+    const style = window.getComputedStyle(parentElement, null);
+    const paddingTop = +(style.paddingTop.replace('px', ''));
+
+		return offsetY + paddingTop;
+  }
+
+  /**
+	 * Attempt to scroll preview element for a source line in the editor.
+	 *
+   * @param {Element} previewElement
+	 * @param {number} line
+	 */
+	scrollPreview(previewElement, line) {
+		const { previous, next } = this.getElementsForSourceLine(previewElement, line);
+		if (previous) {
+			let scrollTo = 0;
+			if (next) {
+				// Between two elements. Go to percentage offset between them.
+				const betweenProgress = (line - previous.line) / (next.line - previous.line);
+				const elementOffset = next.element.getBoundingClientRect().top - previous.element.getBoundingClientRect().top;
+				scrollTo = previous.element.getBoundingClientRect().top + betweenProgress * elementOffset;
+			} else {
+				scrollTo = previous.element.getBoundingClientRect().top;
+      }
+
+      scrollTo -= this.getParentElementOffset(previewElement);
+
+      previewElement.scroll(0, previewElement.scrollTop + scrollTo);
+		}
+  }
+
+  /**
+	 * Attempt to reveal the element that is overflowing from previewElement.
+	 *
+   * @param {Element} previewElement
+	 * @param {number} line
+	 */
+  scrollPreviewToRevealOverflowing(previewElement, line) {
+		const { previous, next } = this.getElementsForSourceLine(previewElement, line);
+		if (previous) {
+      const parentElementOffset = this.getParentElementOffset(previewElement);
+      const prevElmTop = previous.element.getBoundingClientRect().top - parentElementOffset;
+      const prevElmBottom = previous.element.getBoundingClientRect().bottom - parentElementOffset;
+
+      let scrollTo = null;
+      if (prevElmTop < 0) {
+        // set the top of 'previous.element' to the top of 'previewElement'
+        scrollTo = previewElement.scrollTop + prevElmTop;
+      }
+      else if (prevElmBottom > previewElement.clientHeight) {
+        // set the bottom of 'previous.element' to the bottom of 'previewElement'
+        scrollTo = previewElement.scrollTop + prevElmBottom - previewElement.clientHeight + 20;
+      }
+
+      if (scrollTo == null) {
+        return;
+      }
+
+      previewElement.scroll(0, scrollTo);
+		}
+  }
+
+  /**
+   * Attempt to scroll Editor component for the offset of the element in the Preview component.
+   *
+   * @param {Editor} editor
+   * @param {Element} previewElement
+   * @param {number} offset
+   */
+  scrollEditor(editor, previewElement, offset) {
+    let line = this.getEditorLineNumberForPageOffset(previewElement, offset);
+    line = Math.floor(line);
+    editor.setScrollTopByLine(line);
+  }
+}
+
+// singleton pattern
+const instance = new ScrollSyncHelper();
+Object.freeze(instance);
+export default instance;

+ 3 - 2
resource/js/components/SearchPage.js

@@ -101,10 +101,10 @@ export default class SearchPage extends React.Component {
         </div>
 
         <SearchResult
+          crowi={this.props.crowi} crowiRenderer={this.props.crowiRenderer}
           pages={this.state.searchedPages}
           searchingKeyword={this.state.searchingKeyword}
           searchResultMeta={this.state.searchResultMeta}
-          crowi={this.props.crowi}
           />
       </div>
     );
@@ -112,8 +112,9 @@ export default class SearchPage extends React.Component {
 }
 
 SearchPage.propTypes = {
-  query: PropTypes.object,
   crowi: PropTypes.object.isRequired,
+  crowiRenderer: PropTypes.object.isRequired,
+  query: PropTypes.object,
 };
 SearchPage.defaultProps = {
   //pollInterval: 1000,

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

@@ -261,6 +261,7 @@ export default class SearchResult extends React.Component {
           </div>
           <div className="col-md-8 search-result-content" id="search-result-content">
             <SearchResultList
+              crowi={this.props.crowi} crowiRenderer={this.props.crowiRenderer}
               pages={this.props.pages}
               searchingKeyword={this.props.searchingKeyword}
               />
@@ -281,11 +282,11 @@ export default class SearchResult extends React.Component {
 }
 
 SearchResult.propTypes = {
+  crowi: PropTypes.object.isRequired,
   tree: PropTypes.string.isRequired,
   pages: PropTypes.array.isRequired,
   searchingKeyword: PropTypes.string.isRequired,
   searchResultMeta: PropTypes.object.isRequired,
-  crowi: PropTypes.object.isRequired,
 };
 SearchResult.defaultProps = {
   tree: '',

+ 14 - 18
resource/js/components/SearchPage/SearchResultList.js

@@ -1,39 +1,33 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import PageBody from '../Page/PageBody.js';
+import GrowiRenderer from '../../util/GrowiRenderer';
+
+import Page from '../Page.js';
 
 export default class SearchResultList extends React.Component {
 
   constructor(props) {
     super(props);
+
+    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiRenderer, {mode: 'searchresult'});
   }
 
   render() {
     var isEnabledLineBreaks = $('#content-main').data('linebreaks-enabled');
 
-    // generate options obj
-    var rendererOptions = {
-      // see: https://www.npmjs.com/package/marked
-      marked: {
-        breaks: isEnabledLineBreaks
-      }
-    };
-
     const resultList = this.props.pages.map((page) => {
       const pageBody = page.revision.body;
       return (
         <div id={page._id} key={page._id} className="search-result-page">
           <h2><a href={page.path}>{page.path}</a></h2>
-          <div className="wiki">
-            <PageBody
-              className="hige"
-              page={page}
-              pageBody={pageBody}
-              highlightKeywords={this.props.searchingKeyword}
-              rendererOptions={rendererOptions}
-            />
-          </div>
+          <Page
+            crowi={this.props.crowi}
+            crowiRenderer={this.growiRenderer}
+            markdown={pageBody}
+            pagePath={page.path}
+            highlightKeywords={this.props.searchingKeyword}
+          />
         </div>
       );
     });
@@ -47,6 +41,8 @@ export default class SearchResultList extends React.Component {
 }
 
 SearchResultList.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  crowiRenderer: PropTypes.object.isRequired,
   pages: PropTypes.array.isRequired,
   searchingKeyword: PropTypes.string.isRequired,
 };

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

@@ -1,6 +1,6 @@
-import {noop} from 'lodash';
 import React from 'react';
 
+import { noop } from 'lodash/noop';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
 import UserPicture from './User/UserPicture';

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

@@ -82,11 +82,11 @@ $(function() {
     crowi.interceptorManager.process('preRenderPreview', context)
       .then(() => crowi.interceptorManager.process('prePreProcess', context))
       .then(() => {
-        context.markdown = crowiRenderer.preProcess(context.markdown, context.dom);
+        context.markdown = crowiRenderer.preProcess(context.markdown);
       })
       .then(() => crowi.interceptorManager.process('postPreProcess', context))
       .then(() => {
-        var parsedHTML = crowiRenderer.render(context.markdown, context.dom, rendererOptions);
+        var parsedHTML = crowiRenderer.render(context.markdown, context.dom);
         context['parsedHTML'] = parsedHTML;
       })
       .then(() => crowi.interceptorManager.process('postRenderPreview', context))

+ 0 - 1
resource/js/legacy/crowi-presentation.js

@@ -35,7 +35,6 @@ require.ensure([], () => {
   require('reveal.js/lib/js/classList.js');
   require('reveal.js/plugin/markdown/marked.js');
   require('reveal.js/plugin/markdown/markdown.js');
-  require('reveal.js/plugin/highlight/highlight.js');
   require('reveal.js/plugin/zoom-js/zoom.js');
   require('reveal.js/plugin/notes/notes.js');
 

+ 58 - 108
resource/js/legacy/crowi.js

@@ -2,10 +2,15 @@
 /* Author: Sotaro KARASAWA <sotarok@crocos.co.jp>
 */
 
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+import GrowiRenderer from '../util/GrowiRenderer';
+import Page from '../components/Page';
+
 const io = require('socket.io-client');
 const entities = require("entities");
 const escapeStringRegexp = require('escape-string-regexp');
-const getLineFromPos = require('get-line-from-pos');
 require('bootstrap-sass');
 require('jquery.cookie');
 
@@ -20,36 +25,20 @@ Crowi.createErrorView = function(msg) {
   $('#main').prepend($('<p class="alert-message error">' + msg + '</p>'));
 };
 
-Crowi.correctHeaders = function(contentId) {
-  // h1 ~ h6 の id 名を補正する
-  var $content = $(contentId || '#revision-body-content');
-  var i = 0;
-  $('h1,h2,h3,h4,h5,h6', $content).each(function(idx, elm) {
-    var id = 'head' + i++;
-    $(this).attr('id', id);
-    $(this).addClass('revision-head');
-    $(this).append('<span class="revision-head-link"><a href="#' + id +'"><i class="fa fa-link"></i></a></span>');
-  });
-};
+/**
+ * render Table Of Contents
+ * @param {string} tocHtml
+ */
+Crowi.renderTocContent = (tocHtml) => {
+  $('#revision-toc-content').html(tocHtml);
+}
 
 /**
  * append buttons to section headers
  */
-Crowi.appendEditSectionButtons = function(contentId, markdown) {
-  const $content = $(contentId || '#revision-body-content');
-  $('h1,h2,h3,h4,h5,h6', $content).each(function(idx, elm) {
-    // get header text string
-    const text = $(this).text();
-    const escapedText = escapeStringRegexp(text);
-
-    // search pos for '# ...'
-    // https://regex101.com/r/y5rpO5/1
-    const regexp = new RegExp(`[^\r\n]*#+[^\r\n]*${escapedText}[^\r\n]*`);
-    let position = markdown.search(regexp);
-    if (position < 0) { // if not found, search with header text only
-      position = markdown.search(text);
-    }
-    const line = getLineFromPos(markdown, position);
+Crowi.appendEditSectionButtons = function(parentElement) {
+  $('h1,h2,h3,h4,h5,h6', parentElement).each(function(idx, elm) {
+    const line = +elm.getAttribute('data-line');
 
     // add button
     $(this).append(`
@@ -98,52 +87,6 @@ Crowi.setCaretLineAndFocusToEditor = function() {
   crowi.focusToEditor();
 }
 
-Crowi.revisionToc = function(contentId, tocId) {
-  var $content = $(contentId || '#revision-body-content');
-  var $tocId = $(tocId || '#revision-toc');
-
-  var $tocContent = $('<div id="revision-toc-content" class="revision-toc-content collapse in"></div>');
-  $tocId.append($tocContent);
-
-  $('h1', $content).each(function(idx, elm) {
-    var id = $(this).attr('id');
-    var title = $(this).text();
-    var selector = '#' + id + ' ~ h2:not(#' + id + ' ~ h1 ~ h2)';
-
-    var $toc = $('<ul></ul>');
-    var $tocLi = $('<li><a href="#' + id +'">' + title + '</a></li>');
-
-
-    $tocContent.append($toc);
-    $toc.append($tocLi);
-
-    $(selector).each(function()
-    {
-      var id2 = $(this).attr('id');
-      var title2 = $(this).text();
-      var selector2 = '#' + id2 + ' ~ h3:not(#' + id2 + ' ~ h2 ~ h3)';
-
-      var $toc2 = $('<ul></ul>');
-      var $tocLi2 = $('<li><a href="#' + id2 +'">' + title2 + '</a></li>');
-
-      $tocLi.append($toc2);
-      $toc2.append($tocLi2);
-
-      $(selector2).each(function()
-      {
-        var id3 = $(this).attr('id');
-        var title3 = $(this).text();
-
-        var $toc3 = $('<ul></ul>');
-        var $tocLi3 = $('<li><a href="#' + id3 +'">' + title3 + '</a></li>');
-
-        $tocLi2.append($toc3);
-        $toc3.append($tocLi3);
-      });
-    });
-  });
-};
-
 // original: middleware.swigFilter
 Crowi.userPicture = function (user) {
   if (!user) {
@@ -167,7 +110,7 @@ Crowi.modifyScrollTop = function() {
   }
   var pageHeaderRect = pageHeader.getBoundingClientRect();
 
-  var sectionHeader = document.querySelector(hash);
+  var sectionHeader = Crowi.findSectionHeader(hash);
   if (sectionHeader === null) {
     return;
   }
@@ -227,14 +170,6 @@ $(function() {
   var pagePath= $('#content-main').data('path');
   var isSavedStatesOfTabChanges = config['isSavedStatesOfTabChanges'];
 
-  // generate options obj
-  var rendererOptions = {
-    // see: https://www.npmjs.com/package/marked
-    marked: {
-      breaks: config.isEnabledLineBreaks
-    }
-  };
-
   $('[data-toggle="popover"]').popover();
   $('[data-toggle="tooltip"]').tooltip();
   $('[data-tooltip-stay]').tooltip('show');
@@ -486,29 +421,26 @@ $(function() {
   });
 
   // for list page
+  let growiRendererForTimeline = null;
   $('a[data-toggle="tab"][href="#view-timeline"]').on('show.bs.tab', function() {
     var isShown = $('#view-timeline').data('shown');
+
+    if (growiRendererForTimeline == null) {
+      growiRendererForTimeline = new GrowiRenderer(crowi, crowiRenderer, {mode: 'timeline'});
+    }
+
     if (isShown == 0) {
       $('#view-timeline .timeline-body').each(function()
       {
         var id = $(this).attr('id');
         var contentId = '#' + id + ' > script';
         var revisionBody = '#' + id + ' .revision-body';
-        var $revisionBody = $(revisionBody);
+        var revisionBodyElem = document.querySelector(revisionBody);
         var revisionPath = '#' + id + ' .revision-path';
-
+        var pagePath = document.getElementById(id).getAttribute('data-page-path');
         var markdown = entities.decodeHTML($(contentId).html());
-        var parsedHTML = crowiRenderer.render(markdown, $revisionBody.get(0), rendererOptions);
-        $revisionBody.html(parsedHTML);
 
-        $('.template-create-button', revisionBody).on('click', function() {
-          var path = $(this).data('path');
-          var templateId = $(this).data('template');
-          var template = $('#' + templateId).html();
-
-          crowi.saveDraft(path, template);
-          top.location.href = path;
-        });
+        ReactDOM.render(<Page crowi={crowi} crowiRenderer={growiRendererForTimeline} markdown={markdown} pagePath={pagePath} />, revisionBodyElem);
       });
 
       $('#view-timeline').data('shown', 1);
@@ -540,6 +472,10 @@ $(function() {
 
   if (pageId) {
 
+    /*
+     * transplanted to React components -- 2018.02.04 Yuki Takei
+     *
+
     // if page exists
     var $rawTextOriginal = $('#raw-text-original');
     if ($rawTextOriginal.length > 0) {
@@ -556,12 +492,12 @@ $(function() {
       crowi.interceptorManager.process('preRender', context)
         .then(() => crowi.interceptorManager.process('prePreProcess', context))
         .then(() => {
-          context.markdown = crowiRenderer.preProcess(context.markdown, context.dom);
+          context.markdown = crowiRenderer.preProcess(context.markdown);
         })
         .then(() => crowi.interceptorManager.process('postPreProcess', context))
         .then(() => {
           var revisionBody = $('#revision-body-content');
-          var parsedHTML = crowiRenderer.render(context.markdown, context.dom, rendererOptions);
+          var parsedHTML = crowiRenderer.render(context.markdown, context.dom);
           context.parsedHTML = parsedHTML;
           Promise.resolve(context);
         })
@@ -580,9 +516,7 @@ $(function() {
             top.location.href = path;
           });
 
-          Crowi.correctHeaders('#revision-body-content');
           Crowi.appendEditSectionButtons('#revision-body-content', markdown);
-          Crowi.revisionToc('#revision-body-content', '#revision-toc');
 
           Promise.resolve($('#revision-body-content'));
         })
@@ -594,6 +528,7 @@ $(function() {
 
 
     }
+    */
 
     // header
     var $header = $('#page-header');
@@ -883,28 +818,43 @@ Crowi.findHashFromUrl = function(url)
 {
   var match;
   if (match = url.match(/#(.+)$/)) {
-    return '#' + match[1];
+    return `#${match[1]}`;
   }
 
   return "";
 }
 
+Crowi.findSectionHeader = function(hash) {
+  if (hash.length == 0) {
+    return;
+  }
+
+  // omit '#'
+  const id = hash.replace('#', '');
+  // don't use jQuery and document.querySelector
+  //  because hash may containe Base64 encoded strings
+  const elem = document.getElementById(id);
+  if (elem != null && elem.tagName.match(/h\d+/i)) {  // match h1, h2, h3...
+    return elem;
+  }
+
+  return null;
+}
+
 Crowi.unhighlightSelectedSection = function(hash)
 {
-  if (!hash || hash == "" || !hash.match(/^#head.+/)) {
-    // とりあえず head* だけ (検索結果ページで副作用出た
-    return true;
+  const elem = Crowi.findSectionHeader(hash);
+  if (elem != null) {
+    elem.classList.remove('highlighted');
   }
-  $(hash).removeClass('highlighted');
 }
 
 Crowi.highlightSelectedSection = function(hash)
 {
-  if (!hash || hash == "" || !hash.match(/^#head.+/)) {
-    // とりあえず head* だけ (検索結果ページで副作用出た
-    return true;
+  const elem = Crowi.findSectionHeader(hash);
+  if (elem != null) {
+    elem.classList.add('highlighted');
   }
-  $(hash).addClass('highlighted');
 }
 
 window.addEventListener('load', function(e) {
@@ -971,7 +921,7 @@ window.addEventListener('hashchange', function(e) {
       $('a[data-toggle="tab"][href="#revision-history"]').tab('show');
     }
   }
-  if (location.hash == '' || location.hash.match(/^#head.+/)) {
+  else {
     $('a[data-toggle="tab"][href="#revision-body"]').tab('show');
   }
 });

+ 6 - 0
resource/js/util/Crowi.js

@@ -4,6 +4,7 @@
 
 import axios from 'axios'
 import InterceptorManager from '../../../lib/util/interceptor-manager';
+
 import {
   DetachCodeBlockInterceptor,
   RestoreCodeBlockInterceptor,
@@ -74,6 +75,7 @@ export default class Crowi {
       'users',
       'draft',
       'editorOptions',
+      'previewOptions',
     ];
 
     keys.forEach(key => {
@@ -153,6 +155,10 @@ export default class Crowi {
     this.localStorage.setItem('editorOptions', JSON.stringify(options));
   }
 
+  savePreviewOptions(options) {
+    this.localStorage.setItem('previewOptions', JSON.stringify(options));
+  }
+
   findUserById(userId) {
     if (this.userById && this.userById[userId]) {
       return this.userById[userId];

+ 12 - 3
resource/js/util/CrowiRenderer.js

@@ -1,3 +1,7 @@
+/**
+ * DEPRECATED
+ * replaced by GrowiRenderer -- 2018.01.29 Yuki Takei
+ *
 import marked from 'marked';
 import hljs from 'highlight.js';
 import * as entities from 'entities';
@@ -16,7 +20,6 @@ import PlantUML from './LangProcessor/PlantUML';
 
 export default class CrowiRenderer {
 
-
   constructor(crowi) {
     this.crowi = crowi;
 
@@ -134,6 +137,7 @@ export default class CrowiRenderer {
 
     return parsed;
   }
+  */
 
   /**
    * render
@@ -150,12 +154,17 @@ export default class CrowiRenderer {
    *
    * @memberOf CrowiRenderer
    */
-  render(markdown, dom, rendererOptions) {
+  /*
+   DEPRECATED
+   replaced by GrowiRenderer -- 2018.01.29 Yuki Takei
+
+  render(markdown, dom) {
     let html = '';
 
-    html = this.parseMarkdown(markdown, dom, rendererOptions.marked || {});
+    html = this.parseMarkdown(markdown, dom);
     html = this.postProcess(html, dom);
 
     return html;
   }
 }
+*/

+ 166 - 0
resource/js/util/GrowiRenderer.js

@@ -0,0 +1,166 @@
+import MarkdownIt from 'markdown-it';
+import * as entities from 'entities';
+
+import Linker        from './PreProcessor/Linker';
+import CsvToTable    from './PreProcessor/CsvToTable';
+import XssFilter     from './PreProcessor/XssFilter';
+
+import Template from './LangProcessor/Template';
+
+import CommonPluginsConfigurer from './markdown-it/common-plugins';
+import EmojiConfigurer from './markdown-it/emoji';
+import HeaderLineNumberConfigurer from './markdown-it/header-line-number';
+import HeaderConfigurer from './markdown-it/header';
+import MathJaxConfigurer from './markdown-it/mathjax';
+import PlantUMLConfigurer from './markdown-it/plantuml';
+import TableConfigurer from './markdown-it/table';
+import TocAndAnchorConfigurer from './markdown-it/toc-and-anchor';
+
+export default class GrowiRenderer {
+
+  /**
+   *
+   * @param {Crowi} crowi
+   * @param {GrowiRenderer} originRenderer may be customized by plugins
+   * @param {object} options
+   */
+  constructor(crowi, originRenderer, options) {
+    this.crowi = crowi;
+    this.originRenderer = originRenderer || {};
+    this.options = Object.assign( // merge options
+      { isAutoSetup: true },      // default options
+      options || {});             // specified options
+
+    // initialize processors
+    //  that will be retrieved if originRenderer exists
+    this.preProcessors = this.originRenderer.preProcessors || [
+      new Linker(crowi),
+      new CsvToTable(crowi),
+      new XssFilter(crowi),
+    ];
+    this.postProcessors = this.originRenderer.postProcessors || [
+    ];
+
+    this.langProcessors = this.originRenderer.langProcessors || {
+      'template': new Template(crowi),
+    };
+
+    this.initMarkdownItConfigurers = this.initMarkdownItConfigurers.bind(this);
+    this.setup = this.setup.bind(this);
+    this.process = this.process.bind(this);
+    this.codeRenderer = this.codeRenderer.bind(this);
+
+    // init markdown-it
+    this.md = new MarkdownIt({
+      html: true,
+      linkify: true,
+      highlight: this.codeRenderer,
+    });
+    this.initMarkdownItConfigurers(options);
+
+    // auto setup
+    if (this.options.isAutoSetup) {
+      this.setup(crowi.getConfig());
+    }
+  }
+
+  initMarkdownItConfigurers(options) {
+    const crowi = this.crowi;
+
+    this.isMarkdownItConfigured = false;
+
+    this.markdownItConfigurers = [
+      new CommonPluginsConfigurer(crowi),
+      new HeaderConfigurer(crowi),
+      new TableConfigurer(crowi),
+      new EmojiConfigurer(crowi),
+      new MathJaxConfigurer(crowi),
+      new PlantUMLConfigurer(crowi),
+    ];
+
+    // add configurers according to mode
+    const mode = options.mode;
+    switch (mode) {
+      case 'page':
+        this.markdownItConfigurers = this.markdownItConfigurers.concat([
+          new TocAndAnchorConfigurer(crowi, options.renderToc),
+          new HeaderLineNumberConfigurer(crowi),
+        ]);
+        break;
+      case 'editor':
+        this.markdownItConfigurers = this.markdownItConfigurers.concat([
+          new HeaderLineNumberConfigurer(crowi)
+        ]);
+        break;
+      case 'timeline':
+        break;
+      case 'searchresult':
+        break;
+    }
+  }
+
+  /**
+   * setup with crowi config
+   * @param {any} config crowi config
+   */
+  setup(config) {
+    this.md.set({
+      breaks: config.isEnabledLineBreaks,
+    });
+
+    if (!this.isMarkdownItConfigured) {
+      this.markdownItConfigurers.forEach((configurer) => {
+        configurer.configure(this.md);
+      });
+    }
+  }
+
+  preProcess(markdown) {
+    for (let i = 0; i < this.preProcessors.length; i++) {
+      if (!this.preProcessors[i].process) {
+        continue;
+      }
+      markdown = this.preProcessors[i].process(markdown);
+    }
+
+    return markdown;
+  }
+
+  process(markdown) {
+    return this.md.render(markdown);
+  }
+
+  postProcess(html, dom) {
+    for (let i = 0; i < this.postProcessors.length; i++) {
+      if (!this.postProcessors[i].process) {
+        continue;
+      }
+      html = this.postProcessors[i].process(html, dom);
+    }
+
+    return html;
+  }
+
+  codeRenderer(code, langExt) {
+    if (langExt) {
+      const langAndFn = langExt.split(':');
+      const lang = langAndFn[0];
+      const langFn = langAndFn[1] || null;
+
+      // process langProcessors
+      if (this.langProcessors[lang] != null) {
+        return this.langProcessors[lang].process(code, langExt);
+      }
+
+      if (hljs.getLanguage(lang)) {
+        let citeTag = (langFn) ? `<cite>${langFn}</cite>` : '';
+        try {
+          return `<pre class="hljs">${citeTag}<code class="language-${lang}">${hljs.highlight(lang, code, true).value}</code></pre>`;
+        } catch (__) {}
+      }
+    }
+
+    return '';
+  }
+
+}

+ 0 - 43
resource/js/util/LangProcessor/PlantUML.js

@@ -1,43 +0,0 @@
-import plantuml from 'plantuml-encoder';
-import crypto from 'crypto';
-import * as entities from 'entities';
-
-export default class PlantUML {
-
-  constructor(crowi) {
-    this.crowi = crowi;
-
-  }
-
-  generateId(token) {
-    const hasher = require('crypto').createHash('md5');
-    hasher.update(token);
-    return hasher.digest('hex');
-  }
-
-  process(code, lang) {
-    const config = crowi.getConfig();
-    if (!config.env.PLANTUML_URI) {
-      return `<pre class="wiki-code"><code>${entities.encodeHTML(code)}\n</code></pre>`;
-    }
-
-    let plantumlUri = config.env.PLANTUML_URI;
-    if (plantumlUri.substr(-1) !== '/') {
-      plantumlUri += '/';
-    }
-    const id = this.generateId(code + lang);
-    const encoded = plantuml.encode(`@startuml
-
-skinparam monochrome true
-
-${code}
-@enduml`);
-
-    return `
-      <div id="${id}" class="plantuml noborder">
-        <img src="${plantumlUri}svg/${encoded}">
-      </div>
-    `;
-  }
-}
-

+ 12 - 4
resource/js/util/LangProcessor/Template.js

@@ -56,9 +56,17 @@ export default class Template {
       pageName = this.parseTemplateString(lang.split(':')[1]);
     }
     code = this.parseTemplateString(code);
-    return `
-    <div class="page-template-builder">
-    <button class="template-create-button btn btn-default" data-template="${templateId}" data-path="${pageName}"><i class="fa fa-pencil"></i> ${pageName}</button>
-      <pre><code id="${templateId}" class="lang-${lang}">${code}\n</code></pre></div>\n`;
+
+    const content = `
+      <div class="page-template-builder">
+        <button class="template-create-button btn btn-default" data-template="${templateId}" data-path="${pageName}#edit-form">
+          <i class="fa fa-pencil"></i> ${pageName}
+        </button>
+        <pre><code id="${templateId}" class="lang-${lang}">${code}\n</code></pre>
+      </div>`;
+
+    // wrap with <pre-dummy>
+    //   to avoid to be wrapped with <pre><code> by markdown-it
+    return `<pre-dummy>${content}<pre-dummy>\n`;
   }
 }

+ 0 - 85
resource/js/util/LangProcessor/Tsv2Table.js

@@ -1,85 +0,0 @@
-import * as entities from 'entities';
-
-export default class Tsv2Table {
-
-  constructor(crowi, option) {
-    if (!option) {
-      option = {};
-    }
-    this.option = option;
-
-    this.option.header = this.option.header || false;
-  }
-  getCols(codeLines) {
-    let max = 0;
-
-    for (let i = 0; i < codeLines ; i++) {
-      if (max < codeLines.length) {
-        max = codeLines.length;
-      }
-    }
-
-    return max;
-  }
-
-  splitColums(line) {
-    // \t is replaced to '    ' by Lexer.lex(), so split by 4 spaces
-    return line.split(/\s{4}/g);
-  }
-
-  getTableHeader(codeLines, option) {
-    let headers = [];
-    let headLine = (codeLines[0] || '');
-
-    //console.log('head', headLine);
-    headers = this.splitColums(headLine).map(col => {
-      return `<th>${entities.encodeHTML(col)}</th>`;
-    });
-
-    if (headers.length < option.cols) {
-      headers.concat(new Array(option.cols - headers.length));
-    }
-
-    return `<tr>
-      ${headers.join('\n')}
-    </tr>`;
-  }
-
-  getTableBody(codeLines, option) {
-    let rows;
-
-    if (this.option.header) {
-      codeLines.shift();
-    }
-
-    rows = codeLines.map(row => {
-      const cols = this.splitColums(row).map(col => {
-        return `<td>${entities.encodeHTML(col)}</td>`;
-      }).join('');
-      return `<tr>${cols}</tr>`;
-    });
-
-    return rows.join('\n');
-  }
-
-  process(code) {
-    let option = {};
-    const codeLines = code.split(/\n|\r/);
-
-    option.cols = this.getCols(codeLines);
-
-    let header = '';
-    if (this.option.header) {
-      header = `<thead>
-        ${this.getTableHeader(codeLines, option)}
-      </thead>`;
-    }
-
-    return `<table>
-      ${header}
-      <tbody>
-        ${this.getTableBody(codeLines, option)}
-      </tbody>
-    </table>`;
-  }
-}

+ 5 - 0
resource/js/util/PostProcessor/Emoji.js

@@ -1,6 +1,11 @@
+/**
+ * DEPRECATED
+ * replaced by markdown-it-emoji -- 2018.01.30 Yuki Takei
+ *
 export default class Emoji {
 
   process(markdown) {
     return emojione.shortnameToImage(markdown);
   }
 }
+*/

+ 5 - 0
resource/js/util/PostProcessor/Mathjax.js

@@ -1,3 +1,7 @@
+/**
+ * DEPRECATED
+ * replaced by markdown-it-mathjax and rendering by PageEditor/Preview component -- 2018.01.30 Yuki Takei
+ *
 
 export default class Mathjax {
 
@@ -64,3 +68,4 @@ export default class Mathjax {
     return html;
   }
 }
+*/

+ 26 - 0
resource/js/util/PreProcessor/CsvToTable.js

@@ -0,0 +1,26 @@
+import csvToMarkdownTable from 'csv-to-markdown-table';
+
+export default class CsvToTable {
+  process(markdown) {
+
+    // see: https://regex101.com/r/WR6IvX/3
+    return markdown.replace(/:::\s*(\S+)[\r\n]((.|[\r\n])*?)[\r\n]:::/gm, (all, group1, group2) => {
+      switch (group1) {
+        case 'tsv':
+          return csvToMarkdownTable(group2, '\t');
+          break;
+        case 'tsv-h':
+          return csvToMarkdownTable(group2, '\t', true);
+          break;
+        case 'csv':
+          return csvToMarkdownTable(group2, ',');
+          break;
+        case 'csv-h':
+          return csvToMarkdownTable(group2, ',', true);
+          break;
+        default:
+          return all;
+      }
+    });
+  }
+}

File diff suppressed because it is too large
+ 0 - 0
resource/js/util/emojione/emoji_strategy_shrinked.json


+ 2 - 2
resource/js/util/interceptor/detach-code-blocks.js

@@ -40,8 +40,8 @@ export class DetachCodeBlockInterceptor extends BasicInterceptor {
 
     context.dcbContextMap = {};
 
-    // see: https://regex101.com/r/8PAEcC/2
-    context.markdown = markdown.replace(/(```(.|[\r\n])*?```)|(`[^\r\n]*?`)/gm, (all) => {
+    // see: https://regex101.com/r/8PAEcC/3
+    context.markdown = markdown.replace(/((```|~~~)(.|[\r\n])*?(```|~~~))|(`[^\r\n]*?`)/gm, (all) => {
       // create ID
       const replaceId = 'dcb-' + this.createRandomStr(8);
 

+ 15 - 0
resource/js/util/markdown-it/common-plugins.js

@@ -0,0 +1,15 @@
+export default class CommonPluginsConfigurer {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  configure(md) {
+    md.use(require('markdown-it-footnote'))
+      .use(require('markdown-it-task-lists'), {
+        enabled: true,
+      })
+      ;
+  }
+
+}

+ 27 - 0
resource/js/util/markdown-it/emoji.js

@@ -0,0 +1,27 @@
+import emojiStrategy from '../emojione/emoji_strategy_shrinked.json';
+
+export default class EmojiConfigurer {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  configure(md) {
+    const emojiShortnameUnicodeMap = {};
+
+    for (let unicode in emojiStrategy) {
+      const data = emojiStrategy[unicode];
+      const shortname = data.shortname.replace(/\:/g, '');
+      emojiShortnameUnicodeMap[shortname] = String.fromCharCode(unicode);
+    }
+
+    md.use(require('markdown-it-emoji'), {defs: emojiShortnameUnicodeMap});
+
+    // integrate markdown-it-emoji and emojione
+    md.renderer.rules.emoji = (token, idx) => {
+      const shortname = `:${token[idx].markup}:`;
+      return emojione.shortnameToImage(shortname);
+    };
+  }
+
+}

+ 34 - 0
resource/js/util/markdown-it/header-line-number.js

@@ -0,0 +1,34 @@
+export default class HeaderLineNumberConfigurer {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.firstLine = 0;
+  }
+
+  configure(md) {
+    for (const renderName of ['paragraph_open', 'heading_open', 'image', 'code_block', 'blockquote_open', 'list_item_open']) {
+      this.addLineNumberRenderer(md, renderName);
+    }
+  }
+
+  /**
+   * Add line numbers for sync scroll
+   * @see https://github.com/Microsoft/vscode/blob/6e8d4d057bd1152d49a1e9780ec6db6363593855/extensions/markdown/src/markdownEngine.ts#L118
+   */
+  addLineNumberRenderer(md, ruleName) {
+		const original = md.renderer.rules[ruleName];
+		md.renderer.rules[ruleName] = (tokens, idx, options, env, self) => {
+			const token = tokens[idx];
+			if (token.map && token.map.length) {
+				token.attrSet('data-line', this.firstLine + token.map[0]);
+				token.attrJoin('class', 'code-line');
+			}
+
+			if (original) {
+				return original(tokens, idx, options, env, self);
+			} else {
+				return self.renderToken(tokens, idx, options, env, self);
+			}
+		};
+}
+}

+ 36 - 0
resource/js/util/markdown-it/header.js

@@ -0,0 +1,36 @@
+export default class HeaderConfigurer {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+
+    this.injectRevisionHeadClass = this.injectRevisionHeadClass.bind(this);
+  }
+
+  configure(md) {
+    const rules = md.renderer.rules;
+    const original = rules.heading_open;
+    // combine rule and set
+    // rules.heading_open = this.combineRules(this.injectRevisionHeadClass, headingOpenOrg);
+    rules.heading_open = (tokens, idx, options, env, self) => {
+      const token = tokens[idx];
+
+      // Inject 'revision-head' class
+			this.injectRevisionHeadClass(tokens, idx, options, env, self);
+
+			if (original) {
+				return original(tokens, idx, options, env, self);
+			} else {
+				return self.renderToken(tokens, idx, options, env, self);
+			}
+		};
+  }
+
+  /**
+   * Inject 'revision-head' class
+   */
+  injectRevisionHeadClass(tokens, idx, options, env, slf) {
+    if (tokens[idx].map && tokens[idx].level === 0) {
+      tokens[idx].attrJoin('class', 'revision-head');
+    }
+  }
+}

+ 16 - 0
resource/js/util/markdown-it/mathjax.js

@@ -0,0 +1,16 @@
+export default class MathJaxConfigurer {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+
+    const config = crowi.getConfig();
+    this.isEnabled = !!config.env.MATHJAX;  // convert to boolean
+  }
+
+  configure(md) {
+    if (this.isEnabled) {
+      md.use(require('markdown-it-mathjax')());
+    }
+  }
+
+}

+ 26 - 0
resource/js/util/markdown-it/plantuml.js

@@ -0,0 +1,26 @@
+import urljoin from 'url-join';
+
+export default class PlantUMLConfigurer {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    const config = crowi.getConfig();
+
+    this.deflate = require('markdown-it-plantuml/lib/deflate.js');
+    this.serverUrl = config.env.PLANTUML_URI || 'http://plantuml.com';
+
+    this.generateSource = this.generateSource.bind(this);
+  }
+
+  configure(md) {
+    md.use(require('markdown-it-plantuml'), 'name', {
+      generateSource: this.generateSource,
+    });
+  }
+
+  generateSource(umlCode) {
+    const zippedCode =
+      this.deflate.encode64(this.deflate.zip_deflate('@startuml\n' + umlCode + '\n@enduml', 9));
+    return urljoin(this.serverUrl, 'plantuml', 'svg' , zippedCode);
+  }
+}

+ 13 - 0
resource/js/util/markdown-it/table.js

@@ -0,0 +1,13 @@
+export default class TableConfigurer {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  configure(md) {
+    md.renderer.rules.table_open = (tokens, idx) => {
+      return '<table class="table table-bordered">';
+    };
+  }
+
+}

+ 38 - 0
resource/js/util/markdown-it/toc-and-anchor.js

@@ -0,0 +1,38 @@
+import uslug from 'uslug';
+
+export default class TocAndAnchorConfigurer {
+
+  constructor(crowi, renderToc) {
+    this.crowi = crowi;
+    this.renderToc = renderToc;
+  }
+
+  configure(md) {
+    md.use(require('markdown-it-toc-and-anchor-with-slugid').default, {
+        tocLastLevel: 3,
+        anchorLinkBefore: false,
+        anchorLinkSymbol: '',
+        anchorLinkSymbolClassName: 'fa fa-link',
+        anchorClassName: 'revision-head-link',
+        slugify: this.customSlugify,
+      })
+      ;
+
+    // set toc render function
+    if (this.renderToc != null) {
+      md.set({
+        tocCallback: (tocMarkdown, tocArray, tocHtml) => {
+          this.renderToc(tocHtml);
+        },
+      });
+    }
+  }
+
+  /**
+   * create Base64 encoded id
+   * @param {string} header
+   */
+  customSlugify(header) {
+    return encodeURIComponent(uslug(header.trim()));
+  }
+}

+ 0 - 1
resource/styles/index.js

@@ -1,2 +1 @@
 import '../css/crowi.scss';
-import 'highlight.js/styles/github.css';

+ 84 - 24
yarn.lock

@@ -1394,6 +1394,10 @@ clone@^1.0.2:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.3.tgz#298d7e2231660f40c003c2ed3140decf3f53085f"
 
+clone@^2.1.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.1.tgz#d217d1e961118e3ac9a4b8bba3285553bf647cdb"
+
 co@^4.6.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -1753,6 +1757,10 @@ csso@~2.3.1:
     clap "^1.0.9"
     source-map "^0.5.3"
 
+csv-to-markdown-table@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/csv-to-markdown-table/-/csv-to-markdown-table-0.4.0.tgz#9040fd58a7bb963f515652f31e48ead8ca516fc7"
+
 currently-unhandled@^0.4.1:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"
@@ -2069,7 +2077,7 @@ enhanced-resolve@^3.4.0:
     object-assign "^4.0.1"
     tapable "^0.2.7"
 
-entities@^1.1.1:
+entities@^1.1.1, entities@~1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0"
 
@@ -2575,10 +2583,6 @@ get-func-name@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41"
 
-get-line-from-pos@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/get-line-from-pos/-/get-line-from-pos-1.0.0.tgz#e3ca483035eef374ad40fff4d43df3fa2da328b3"
-
 get-stdin@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
@@ -2810,10 +2814,6 @@ he@1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd"
 
-highlight.js@^9.10.0:
-  version "9.12.0"
-  resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.12.0.tgz#e6d9dbe57cbefe60751f02af336195870c90c01e"
-
 hmac-drbg@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
@@ -3357,6 +3357,12 @@ ldapjs@^1.0.1:
   optionalDependencies:
     dtrace-provider "^0.7.0"
 
+linkify-it@^2.0.0:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-2.0.3.tgz#d94a4648f9b1c179d64fa97291268bdb6ce9434f"
+  dependencies:
+    uc.micro "^1.0.1"
+
 livereload-js@^2.2.2:
   version "2.2.2"
   resolved "https://registry.yarnpkg.com/livereload-js/-/livereload-js-2.2.2.tgz#6c87257e648ab475bc24ea257457edcc1f8d0bc2"
@@ -3660,6 +3666,49 @@ map-values@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/map-values/-/map-values-1.0.1.tgz#768b8e79c009bf2b64fee806e22a7b1c4190c990"
 
+markdown-it-emoji@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/markdown-it-emoji/-/markdown-it-emoji-1.4.0.tgz#9bee0e9a990a963ba96df6980c4fddb05dfb4dcc"
+
+markdown-it-footnote@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/markdown-it-footnote/-/markdown-it-footnote-3.0.1.tgz#7f3730747cacc86e2fe0bf8a17a710f34791517a"
+
+markdown-it-mathjax@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/markdown-it-mathjax/-/markdown-it-mathjax-2.0.0.tgz#ae2b4f4c5c719a03f9e475c664f7b2685231d9e9"
+
+markdown-it-named-headers@^0.0.4:
+  version "0.0.4"
+  resolved "https://registry.yarnpkg.com/markdown-it-named-headers/-/markdown-it-named-headers-0.0.4.tgz#82efc28324240a6b1e77b9aae501771d5f351c1f"
+  dependencies:
+    string "^3.0.1"
+
+markdown-it-plantuml@^0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/markdown-it-plantuml/-/markdown-it-plantuml-0.3.1.tgz#f338df4d691a5561364e65809b6812bcb3d8b047"
+
+markdown-it-task-lists@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/markdown-it-task-lists/-/markdown-it-task-lists-2.1.0.tgz#4594f750f70df053d1dad68024388007c1d20783"
+
+markdown-it-toc-and-anchor-with-slugid@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/markdown-it-toc-and-anchor-with-slugid/-/markdown-it-toc-and-anchor-with-slugid-1.1.2.tgz#14d293ee530891107a13575407ede0376fb3666e"
+  dependencies:
+    clone "^2.1.0"
+    uslug "^1.0.4"
+
+markdown-it@^8.4.0:
+  version "8.4.0"
+  resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-8.4.0.tgz#e2400881bf171f7018ed1bd9da441dac8af6306d"
+  dependencies:
+    argparse "^1.0.7"
+    entities "~1.1.1"
+    linkify-it "^2.0.0"
+    mdurl "^1.0.1"
+    uc.micro "^1.0.3"
+
 marked-terminal@^1.6.2:
   version "1.7.0"
   resolved "https://registry.yarnpkg.com/marked-terminal/-/marked-terminal-1.7.0.tgz#c8c460881c772c7604b64367007ee5f77f125904"
@@ -3670,7 +3719,7 @@ marked-terminal@^1.6.2:
     lodash.assign "^4.2.0"
     node-emoji "^1.4.1"
 
-marked@^0.3.12, marked@^0.3.6:
+marked@^0.3.6:
   version "0.3.12"
   resolved "https://registry.yarnpkg.com/marked/-/marked-0.3.12.tgz#7cf25ff2252632f3fe2406bde258e94eee927519"
 
@@ -3693,6 +3742,10 @@ md5@^2.2.1:
     crypt "~0.0.1"
     is-buffer "~1.1.1"
 
+mdurl@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e"
+
 media-typer@0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@@ -4343,10 +4396,6 @@ p-try@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3"
 
-pako@1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.3.tgz#5f515b0c6722e1982920ae8005eacb0b7ca73ccf"
-
 pako@~1.0.5:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.6.tgz#0101211baa70c4bca4a0f63f2206e97b7dfaf258"
@@ -4561,13 +4610,6 @@ pkginfo@^0.4.0:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.4.1.tgz#b5418ef0439de5425fc4995042dced14fb2a84ff"
 
-plantuml-encoder@^1.2.4:
-  version "1.2.4"
-  resolved "https://registry.yarnpkg.com/plantuml-encoder/-/plantuml-encoder-1.2.4.tgz#5f0056f7c04bd76aeef420bfcddef339e9f44081"
-  dependencies:
-    pako "1.0.3"
-    utf8-bytes "0.0.1"
-
 postcss-calc@^5.2.0:
   version "5.3.1"
   resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-5.3.1.tgz#77bae7ca928ad85716e2fda42f261bf7c1d65b5e"
@@ -5808,6 +5850,10 @@ string-width@^2.0.0:
     is-fullwidth-code-point "^2.0.0"
     strip-ansi "^4.0.0"
 
+string@^3.0.1:
+  version "3.3.3"
+  resolved "https://registry.yarnpkg.com/string/-/string-3.3.3.tgz#5ea211cd92d228e184294990a6cc97b366a77cb0"
+
 string_decoder@^1.0.0, string_decoder@~1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.0.3.tgz#0fc67d7c141825de94282dd536bec6b9bce860ab"
@@ -6045,6 +6091,10 @@ ua-parser-js@^0.7.9:
   version "0.7.17"
   resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.17.tgz#e9ec5f9498b9ec910e7ae3ac626a805c4d09ecac"
 
+uc.micro@^1.0.1, uc.micro@^1.0.3:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.5.tgz#0c65f15f815aa08b560a61ce8b4db7ffc3f45376"
+
 uglify-js@2.6.0:
   version "2.6.0"
   resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.6.0.tgz#25eaa1cc3550e39410ceefafd1cfbb6b6d15f001"
@@ -6123,6 +6173,10 @@ uniqs@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02"
 
+"unorm@>= 1.0.0":
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/unorm/-/unorm-1.4.1.tgz#364200d5f13646ca8bcd44490271335614792300"
+
 unpipe@1.0.0, unpipe@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
@@ -6131,6 +6185,10 @@ url-join@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/url-join/-/url-join-0.0.1.tgz#1db48ad422d3402469a87f7d97bdebfe4fb1e3c8"
 
+url-join@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/url-join/-/url-join-4.0.0.tgz#4d3340e807d3773bda9991f8305acdcc2a665d2a"
+
 url@0.10.3:
   version "0.10.3"
   resolved "https://registry.yarnpkg.com/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64"
@@ -6145,9 +6203,11 @@ url@^0.11.0:
     punycode "1.3.2"
     querystring "0.2.0"
 
-utf8-bytes@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/utf8-bytes/-/utf8-bytes-0.0.1.tgz#116b025448c9b500081cdfbf1f4d6c6c37d8837d"
+uslug@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/uslug/-/uslug-1.0.4.tgz#b9a22f0914e0a86140633dacc302e5f4fa450677"
+  dependencies:
+    unorm ">= 1.0.0"
 
 util-deprecate@~1.0.1:
   version "1.0.2"

Some files were not shown because too many files changed in this diff