Преглед изворни кода

Merge pull request #995 from weseek/imprv/apply-unstated

Imprv/apply unstated
Yuki Takei пре 6 година
родитељ
комит
134ba59604
60 измењених фајлова са 2061 додато и 1739 уклоњено
  1. 1 0
      config/logger/config.dev.js
  2. 117 481
      src/client/js/app.js
  3. 19 5
      src/client/js/components/LikeButton.jsx
  4. 27 10
      src/client/js/components/MyDraftList/MyDraftList.jsx
  5. 28 20
      src/client/js/components/Page.jsx
  6. 19 6
      src/client/js/components/Page/RevisionLoader.jsx
  7. 34 19
      src/client/js/components/Page/RevisionRenderer.jsx
  8. 10 49
      src/client/js/components/Page/TagEditor.jsx
  9. 94 36
      src/client/js/components/Page/TagLabels.jsx
  10. 16 4
      src/client/js/components/Page/TagsInput.jsx
  11. 23 9
      src/client/js/components/PageAttachment.js
  12. 33 27
      src/client/js/components/PageComment/Comment.jsx
  13. 35 55
      src/client/js/components/PageComment/CommentEditor.jsx
  14. 22 11
      src/client/js/components/PageComment/CommentEditorLazyRenderer.jsx
  15. 20 54
      src/client/js/components/PageComments.jsx
  16. 48 58
      src/client/js/components/PageEditor.jsx
  17. 0 1
      src/client/js/components/PageEditor/AbstractEditor.jsx
  18. 0 0
      src/client/js/components/PageEditor/Cheatsheet.jsx
  19. 56 35
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  20. 17 10
      src/client/js/components/PageEditor/Editor.jsx
  21. 0 0
      src/client/js/components/PageEditor/MarkdownTableUtil.jsx
  22. 56 54
      src/client/js/components/PageEditor/OptionsSelector.jsx
  23. 0 47
      src/client/js/components/PageEditor/Preview.js
  24. 50 0
      src/client/js/components/PageEditor/Preview.jsx
  25. 0 0
      src/client/js/components/PageEditor/SimpleCheatsheet.jsx
  26. 0 0
      src/client/js/components/PageEditor/TextAreaEditor.jsx
  27. 39 66
      src/client/js/components/PageEditorByHackmd.jsx
  28. 15 4
      src/client/js/components/PageList/Draft.jsx
  29. 1 3
      src/client/js/components/PageList/PagePath.js
  30. 30 43
      src/client/js/components/PageStatusAlert.jsx
  31. 22 11
      src/client/js/components/RecentCreated/RecentCreated.jsx
  32. 43 44
      src/client/js/components/SavePageControls.jsx
  33. 31 18
      src/client/js/components/SavePageControls/GrantSelector.jsx
  34. 15 3
      src/client/js/components/SearchForm.js
  35. 14 7
      src/client/js/components/SearchPage.js
  36. 15 3
      src/client/js/components/SearchPage/SearchPageForm.js
  37. 26 22
      src/client/js/components/SearchPage/SearchResult.js
  38. 16 8
      src/client/js/components/SearchPage/SearchResultList.js
  39. 15 4
      src/client/js/components/SearchTypeahead.js
  40. 22 38
      src/client/js/components/SlackNotification.jsx
  41. 61 0
      src/client/js/components/UnstatedUtils.jsx
  42. 16 3
      src/client/js/components/User/UserPictureList.jsx
  43. 1 1
      src/client/js/installer.jsx
  44. 35 37
      src/client/js/legacy/crowi.js
  45. 6 6
      src/client/js/plugin.js
  46. 341 0
      src/client/js/services/AppContainer.js
  47. 34 19
      src/client/js/services/CommentContainer.js
  48. 115 0
      src/client/js/services/EditorContainer.js
  49. 221 0
      src/client/js/services/PageContainer.js
  50. 54 0
      src/client/js/services/TagContainer.js
  51. 33 0
      src/client/js/services/WebsocketContainer.js
  52. 0 297
      src/client/js/util/Crowi.js
  53. 47 53
      src/client/js/util/GrowiRenderer.js
  54. 5 2
      src/client/js/util/PostProcessor/CrowiTemplate.js
  55. 0 0
      src/client/js/util/i18n.js
  56. 8 12
      src/client/js/util/reveal/plugins/growi-renderer.js
  57. 30 0
      src/server/models/page-tag-relation.js
  58. 3 37
      src/server/models/page.js
  59. 16 5
      src/server/routes/page.js
  60. 6 2
      src/server/routes/tag.js

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

@@ -27,4 +27,5 @@ module.exports = {
    * configure level for client
    * configure level for client
    */
    */
   'growi:app': 'debug',
   'growi:app': 'debug',
+  'growi:services:*': 'debug',
 };
 };

+ 117 - 481
src/client/js/app.js

@@ -8,13 +8,6 @@ import * as toastr from 'toastr';
 
 
 import loggerFactory from '@alias/logger';
 import loggerFactory from '@alias/logger';
 import Xss from '@commons/service/xss';
 import Xss from '@commons/service/xss';
-import * as entities from 'entities';
-import i18nFactory from './i18n';
-
-
-import Crowi from './util/Crowi';
-// import CrowiRenderer from './util/CrowiRenderer';
-import GrowiRenderer from './util/GrowiRenderer';
 
 
 import HeaderSearchBox from './components/HeaderSearchBox';
 import HeaderSearchBox from './components/HeaderSearchBox';
 import SearchPage from './components/SearchPage';
 import SearchPage from './components/SearchPage';
@@ -23,13 +16,12 @@ import PageEditor from './components/PageEditor';
 // eslint-disable-next-line import/no-duplicates
 // eslint-disable-next-line import/no-duplicates
 import OptionsSelector from './components/PageEditor/OptionsSelector';
 import OptionsSelector from './components/PageEditor/OptionsSelector';
 // eslint-disable-next-line import/no-duplicates
 // eslint-disable-next-line import/no-duplicates
-import { EditorOptions, PreviewOptions } from './components/PageEditor/OptionsSelector';
+import { defaultEditorOptions, defaultPreviewOptions } from './components/PageEditor/OptionsSelector';
 import SavePageControls from './components/SavePageControls';
 import SavePageControls from './components/SavePageControls';
 import PageEditorByHackmd from './components/PageEditorByHackmd';
 import PageEditorByHackmd from './components/PageEditorByHackmd';
 import Page from './components/Page';
 import Page from './components/Page';
 import PageHistory from './components/PageHistory';
 import PageHistory from './components/PageHistory';
 import PageComments from './components/PageComments';
 import PageComments from './components/PageComments';
-import CommentContainer from './components/PageComment/CommentContainer';
 import CommentEditorLazyRenderer from './components/PageComment/CommentEditorLazyRenderer';
 import CommentEditorLazyRenderer from './components/PageComment/CommentEditorLazyRenderer';
 import PageAttachment from './components/PageAttachment';
 import PageAttachment from './components/PageAttachment';
 import PageStatusAlert from './components/PageStatusAlert';
 import PageStatusAlert from './components/PageStatusAlert';
@@ -48,6 +40,12 @@ import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
 import AdminRebuildSearch from './components/Admin/AdminRebuildSearch';
 import AdminRebuildSearch from './components/Admin/AdminRebuildSearch';
 import GroupDeleteModal from './components/GroupDeleteModal/GroupDeleteModal';
 import GroupDeleteModal from './components/GroupDeleteModal/GroupDeleteModal';
 
 
+import AppContainer from './services/AppContainer';
+import PageContainer from './services/PageContainer';
+import CommentContainer from './services/CommentContainer';
+import EditorContainer from './services/EditorContainer';
+import TagContainer from './services/TagContainer';
+import WebsocketContainer from './services/WebsocketContainer';
 
 
 const logger = loggerFactory('growi:app');
 const logger = loggerFactory('growi:app');
 
 
@@ -55,94 +53,35 @@ if (!window) {
   window = {};
   window = {};
 }
 }
 
 
-const userlang = $('body').data('userlang');
-const i18n = i18nFactory(userlang);
-
 // setup xss library
 // setup xss library
 const xss = new Xss();
 const xss = new Xss();
 window.xss = xss;
 window.xss = xss;
 
 
-const mainContent = document.querySelector('#content-main');
-let pageId = null;
-let pageRevisionId = null;
-let pageRevisionCreatedAt = null;
-let pageRevisionIdHackmdSynced = null;
-let hasDraftOnHackmd = false;
-let pageIdOnHackmd = null;
-let pagePath;
-let pageContent = '';
-let markdown = '';
-let slackChannels;
-let pageTags = [];
-let templateTagData = '';
-if (mainContent !== null) {
-  pageId = mainContent.getAttribute('data-page-id') || null;
-  pageRevisionId = mainContent.getAttribute('data-page-revision-id');
-  pageRevisionCreatedAt = +mainContent.getAttribute('data-page-revision-created');
-  pageRevisionIdHackmdSynced = mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null;
-  pageIdOnHackmd = mainContent.getAttribute('data-page-id-on-hackmd') || null;
-  hasDraftOnHackmd = !!mainContent.getAttribute('data-page-has-draft-on-hackmd');
-  pagePath = mainContent.attributes['data-path'].value;
-  slackChannels = mainContent.getAttribute('data-slack-channels') || '';
-  templateTagData = mainContent.getAttribute('data-template-tags') || '';
-  const rawText = document.getElementById('raw-text-original');
-  if (rawText) {
-    pageContent = rawText.innerHTML;
-  }
-  markdown = entities.decodeHTML(pageContent);
-}
-const isLoggedin = document.querySelector('.main-container.nologin') == null;
-
-// FIXME
-const crowi = new Crowi({
-  me: $('body').data('current-username'),
-  isAdmin: $('body').data('is-admin'),
-  csrfToken: $('body').data('csrftoken'),
-}, window);
-window.crowi = crowi;
-crowi.setConfig(JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}'));
-if (isLoggedin) {
-  crowi.fetchUsers();
-}
-const socket = crowi.getWebSocket();
-const socketClientId = crowi.getSocketClientId();
-
-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;
-
 // create unstated container instance
 // create unstated container instance
-const commentContainer = new CommentContainer(crowi, pageId, pagePath, pageRevisionId);
+const appContainer = new AppContainer();
+const websocketContainer = new WebsocketContainer(appContainer);
+const pageContainer = new PageContainer(appContainer);
+const commentContainer = new CommentContainer(appContainer);
+const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
+const tagContainer = new TagContainer(appContainer);
+const injectableContainers = [
+  appContainer, websocketContainer, pageContainer, commentContainer, editorContainer, tagContainer,
+];
 
 
-// FIXME
-const isEnabledPlugins = $('body').data('plugin-enabled');
-if (isEnabledPlugins) {
-  const crowiPlugin = window.crowiPlugin;
-  crowiPlugin.installAll(crowi, crowiRenderer);
-}
+logger.info('unstated containers have been initialized');
 
 
-/**
- * receive tags from PageTagForm
- * @param {Array} tagData new tags
- */
-const setTagData = function(tagData) {
-  pageTags = tagData;
-};
+appContainer.initPlugins();
+appContainer.injectToWindow();
 
 
-/**
- * component store
- */
-const componentInstances = {};
+const i18n = appContainer.i18n;
 
 
 /**
 /**
  * save success handler when reloading is not needed
  * save success handler when reloading is not needed
  * @param {object} page Page instance
  * @param {object} page Page instance
  */
  */
-const saveWithShortcutSuccessHandler = function(page) {
-  const editorMode = crowi.getCrowiForJquery().getCurrentEditorMode();
+const saveWithShortcutSuccessHandler = function(result) {
+  const { page, tags } = result;
+  const { editorMode } = appContainer.state;
 
 
   // show toastr
   // show toastr
   toastr.success(undefined, 'Saved successfully', {
   toastr.success(undefined, 'Saved successfully', {
@@ -155,41 +94,40 @@ const saveWithShortcutSuccessHandler = function(page) {
     extendedTimeOut: '150',
     extendedTimeOut: '150',
   });
   });
 
 
-  pageId = page._id;
-  pageRevisionId = page.revision._id;
-  pageRevisionIdHackmdSynced = page.revisionHackmdSynced;
-
-  // set page id to SavePageControls
-  componentInstances.savePageControls.setPageId(pageId);
+  // update state of PageContainer
+  const newState = {
+    pageId: page._id,
+    revisionId: page.revision._id,
+    revisionCreatedAt: new Date(page.revision.createdAt).getTime() / 1000,
+    remoteRevisionId: page.revision._id,
+    revisionIdHackmdSynced: page.revisionHackmdSynced,
+    hasDraftOnHackmd: page.hasDraftOnHackmd,
+    markdown: page.revision.body,
+    tags,
+  };
+  pageContainer.setState(newState);
+
+  // update state of EditorContainer
+  editorContainer.setState({ tags });
 
 
-  // Page component
-  if (componentInstances.page != null) {
-    componentInstances.page.setMarkdown(page.revision.body);
-  }
   // PageEditor component
   // PageEditor component
-  if (componentInstances.pageEditor != null) {
-    const updateEditorValue = (editorMode !== 'builtin');
-    componentInstances.pageEditor.setMarkdown(page.revision.body, updateEditorValue);
+  const pageEditor = appContainer.getComponentInstance('PageEditor');
+  if (pageEditor != null) {
+    if (editorMode !== 'builtin') {
+      pageEditor.updateEditorValue(newState.markdown);
+    }
   }
   }
   // PageEditorByHackmd component
   // PageEditorByHackmd component
-  if (componentInstances.pageEditorByHackmd != null) {
-    // clear state of PageEditorByHackmd
-    componentInstances.pageEditorByHackmd.clearRevisionStatus(pageRevisionId, pageRevisionIdHackmdSynced);
+  const pageEditorByHackmd = appContainer.getComponentInstance('PageEditorByHackmd');
+  if (pageEditorByHackmd != null) {
     // reset
     // reset
     if (editorMode !== 'hackmd') {
     if (editorMode !== 'hackmd') {
-      componentInstances.pageEditorByHackmd.setMarkdown(page.revision.body, false);
-      componentInstances.pageEditorByHackmd.reset();
+      pageEditorByHackmd.reset();
     }
     }
   }
   }
-  // PageStatusAlert component
-  const pageStatusAlert = componentInstances.pageStatusAlert;
-  // clear state of PageStatusAlert
-  if (componentInstances.pageStatusAlert != null) {
-    pageStatusAlert.clearRevisionStatus(pageRevisionId, pageRevisionIdHackmdSynced);
-  }
 
 
   // hidden input
   // hidden input
-  $('input[name="revision_id"]').val(pageRevisionId);
+  $('input[name="revision_id"]').val(newState.revisionId);
 };
 };
 
 
 const errorHandler = function(error) {
 const errorHandler = function(error) {
@@ -204,27 +142,28 @@ const errorHandler = function(error) {
 };
 };
 
 
 const saveWithShortcut = function(markdown) {
 const saveWithShortcut = function(markdown) {
-  const editorMode = crowi.getCrowiForJquery().getCurrentEditorMode();
+  const { editorMode } = appContainer.state;
+
+  const { pageId, path } = pageContainer.state;
+  let { revisionId } = pageContainer.state;
 
 
-  let revisionId = pageRevisionId;
   // get options
   // get options
-  const options = componentInstances.savePageControls.getCurrentOptionsToSave();
-  options.socketClientId = socketClientId;
-  options.pageTags = pageTags;
+  const options = editorContainer.getCurrentOptionsToSave();
+  options.socketClientId = websocketContainer.getCocketClientId();
+  options.pageTags = editorContainer.state.tags;
 
 
   if (editorMode === 'hackmd') {
   if (editorMode === 'hackmd') {
     // set option to sync
     // set option to sync
     options.isSyncRevisionToHackmd = true;
     options.isSyncRevisionToHackmd = true;
-    // use revisionId of PageEditorByHackmd
-    revisionId = componentInstances.pageEditorByHackmd.getRevisionIdHackmdSynced();
+    revisionId = pageContainer.state.revisionIdHackmdSynced;
   }
   }
 
 
   let promise;
   let promise;
   if (pageId == null) {
   if (pageId == null) {
-    promise = crowi.createPage(pagePath, markdown, options);
+    promise = appContainer.createPage(path, markdown, options);
   }
   }
   else {
   else {
-    promise = crowi.updatePage(pageId, revisionId, markdown, options);
+    promise = appContainer.updatePage(pageId, revisionId, markdown, options);
   }
   }
 
 
   promise
   promise
@@ -233,48 +172,52 @@ const saveWithShortcut = function(markdown) {
 };
 };
 
 
 const saveWithSubmitButtonSuccessHandler = function() {
 const saveWithSubmitButtonSuccessHandler = function() {
-  crowi.clearDraft(pagePath);
-  window.location.href = pagePath;
+  const { path } = pageContainer.state;
+  pageContainer.clearDraft(path);
+  window.location.href = path;
 };
 };
 
 
 const saveWithSubmitButton = function(submitOpts) {
 const saveWithSubmitButton = function(submitOpts) {
-  const editorMode = crowi.getCrowiForJquery().getCurrentEditorMode();
+  const { editorMode } = appContainer.state;
   if (editorMode == null) {
   if (editorMode == null) {
     // do nothing
     // do nothing
     return;
     return;
   }
   }
 
 
-  let revisionId = pageRevisionId;
+  const { pageId, path } = pageContainer.state;
+  let { revisionId } = pageContainer.state;
   // get options
   // get options
-  const options = componentInstances.savePageControls.getCurrentOptionsToSave();
-  options.socketClientId = socketClientId;
-  options.pageTags = pageTags;
+  const options = editorContainer.getCurrentOptionsToSave();
+  options.socketClientId = websocketContainer.getCocketClientId();
+  options.pageTags = editorContainer.state.tags;
 
 
   // set 'submitOpts.overwriteScopesOfDescendants' to options
   // set 'submitOpts.overwriteScopesOfDescendants' to options
   options.overwriteScopesOfDescendants = submitOpts ? !!submitOpts.overwriteScopesOfDescendants : false;
   options.overwriteScopesOfDescendants = submitOpts ? !!submitOpts.overwriteScopesOfDescendants : false;
 
 
   let promise;
   let promise;
   if (editorMode === 'hackmd') {
   if (editorMode === 'hackmd') {
+    const pageEditorByHackmd = appContainer.getComponentInstance('PageEditorByHackmd');
     // get markdown
     // get markdown
-    promise = componentInstances.pageEditorByHackmd.getMarkdown();
+    promise = pageEditorByHackmd.getMarkdown();
     // use revisionId of PageEditorByHackmd
     // use revisionId of PageEditorByHackmd
-    revisionId = componentInstances.pageEditorByHackmd.getRevisionIdHackmdSynced();
+    revisionId = pageContainer.state.revisionIdHackmdSynced;
     // set option to sync
     // set option to sync
     options.isSyncRevisionToHackmd = true;
     options.isSyncRevisionToHackmd = true;
   }
   }
   else {
   else {
+    const pageEditor = appContainer.getComponentInstance('PageEditor');
     // get markdown
     // get markdown
-    promise = Promise.resolve(componentInstances.pageEditor.getMarkdown());
+    promise = Promise.resolve(pageEditor.getMarkdown());
   }
   }
   // create or update
   // create or update
   if (pageId == null) {
   if (pageId == null) {
     promise = promise.then((markdown) => {
     promise = promise.then((markdown) => {
-      return crowi.createPage(pagePath, markdown, options);
+      return appContainer.createPage(path, markdown, options);
     });
     });
   }
   }
   else {
   else {
     promise = promise.then((markdown) => {
     promise = promise.then((markdown) => {
-      return crowi.updatePage(pageId, revisionId, markdown, options);
+      return appContainer.updatePage(pageId, revisionId, markdown, options);
     });
     });
   }
   }
 
 
@@ -283,298 +226,69 @@ const saveWithSubmitButton = function(submitOpts) {
     .catch(errorHandler);
     .catch(errorHandler);
 };
 };
 
 
-// setup renderer after plugins are installed
-crowiRenderer.setup();
-
-// restore draft when the first time to edit
-const draft = crowi.findDraft(pagePath);
-if (!pageRevisionId && draft != null) {
-  markdown = draft;
-}
-
-const pageEditorOptions = new EditorOptions(crowi.editorOptions);
-
 /**
 /**
  * define components
  * define components
  *  key: id of element
  *  key: id of element
  *  value: React Element
  *  value: React Element
  */
  */
-const componentMappings = {
-  'search-top': <I18nextProvider i18n={i18n}><HeaderSearchBox crowi={crowi} /></I18nextProvider>,
-  'search-sidebar': <I18nextProvider i18n={i18n}><HeaderSearchBox crowi={crowi} /></I18nextProvider>,
-  'search-page': <I18nextProvider i18n={i18n}><SearchPage crowi={crowi} crowiRenderer={crowiRenderer} /></I18nextProvider>,
+let componentMappings = {
+  'search-top': <HeaderSearchBox crowi={appContainer} />,
+  'search-sidebar': <HeaderSearchBox crowi={appContainer} />,
+  'search-page': <SearchPage crowi={appContainer} />,
 
 
   // 'revision-history': <PageHistory pageId={pageId} />,
   // 'revision-history': <PageHistory pageId={pageId} />,
-  'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
-  'bookmark-button-lg': <BookmarkButton pageId={pageId} crowi={crowi} size="lg" />,
+  'tags-page': <TagsList crowi={appContainer} />,
 
 
-  'tags-page': <I18nextProvider i18n={i18n}><TagsList crowi={crowi} /></I18nextProvider>,
+  'create-page-name-input': <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} addTrailingSlash />,
 
 
-  'create-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} addTrailingSlash />,
-  'rename-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,
-  'duplicate-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,
+  'page-editor': <PageEditor onSaveWithShortcut={saveWithShortcut} />,
+  'page-editor-options-selector': <OptionsSelector crowi={appContainer} />,
+  'page-status-alert': <PageStatusAlert />,
+  'save-page-controls': <SavePageControls onSubmit={saveWithSubmitButton} />,
 
 
+  'user-created-list': <RecentCreated />,
+  'user-draft-list': <MyDraftList />,
 };
 };
 
 
 // additional definitions if data exists
 // additional definitions if data exists
-let pageComments = null;
-if (pageId) {
-  componentMappings['page-comments-list'] = (
-    <I18nextProvider i18n={i18n}>
-      <Provider inject={[commentContainer]}>
-        <PageComments
-          ref={(elem) => {
-            if (pageComments == null) {
-              pageComments = elem;
-            }
-          }}
-          revisionCreatedAt={pageRevisionCreatedAt}
-          pageId={pageId}
-          pagePath={pagePath}
-          editorOptions={pageEditorOptions}
-          slackChannels={slackChannels}
-          crowi={crowi}
-          crowiOriginRenderer={crowiRenderer}
-          revisionId={pageRevisionId}
-        />
-      </Provider>
-    </I18nextProvider>
-  );
-  componentMappings['page-attachment'] = <PageAttachment pageId={pageId} markdown={markdown} crowi={crowi} />;
+if (pageContainer.state.pageId != null) {
+  componentMappings = Object.assign({
+    'page-editor-with-hackmd': <PageEditorByHackmd onSaveWithShortcut={saveWithShortcut} />,
+    'page-comments-list': <PageComments />,
+    'page-attachment':  <PageAttachment />,
+    'page-comment-write':  <CommentEditorLazyRenderer />,
+    'like-button': <LikeButton pageId={pageContainer.state.pageId} isLiked={pageContainer.state.isLiked} />,
+    'seen-user-list': <UserPictureList userIds={pageContainer.state.seenUserIds} />,
+    'liker-list': <UserPictureList userIds={pageContainer.state.likerUserIds} />,
+    'bookmark-button':  <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} />,
+    'bookmark-button-lg':  <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} size="lg" />,
+    'rename-page-name-input':  <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
+    'duplicate-page-name-input':  <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
+  }, componentMappings);
 }
 }
-if (pagePath) {
-  componentMappings.page = <Page crowi={crowi} crowiRenderer={crowiRenderer} markdown={markdown} pagePath={pagePath} onSaveWithShortcut={saveWithShortcut} />;
-  componentMappings['revision-path'] = <I18nextProvider i18n={i18n}><RevisionPath pageId={pageId} pagePath={pagePath} crowi={crowi} /></I18nextProvider>;
-  componentMappings['tag-label'] = <I18nextProvider i18n={i18n}><TagLabels crowi={crowi} pageId={pageId} sendTagData={setTagData} templateTagData={templateTagData} /></I18nextProvider>;
+if (pageContainer.state.path != null) {
+  componentMappings = Object.assign({
+    // eslint-disable-next-line quote-props
+    'page': <Page onSaveWithShortcut={saveWithShortcut} />,
+    'revision-path':  <RevisionPath pageId={pageContainer.state.pageId} pagePath={pageContainer.state.path} crowi={appContainer} />,
+    'tag-label':  <TagLabels />,
+  }, componentMappings);
 }
 }
 
 
 Object.keys(componentMappings).forEach((key) => {
 Object.keys(componentMappings).forEach((key) => {
   const elem = document.getElementById(key);
   const elem = document.getElementById(key);
   if (elem) {
   if (elem) {
-    componentInstances[key] = ReactDOM.render(componentMappings[key], elem);
+    ReactDOM.render(
+      <I18nextProvider i18n={i18n}>
+        <Provider inject={injectableContainers}>
+          {componentMappings[key]}
+        </Provider>
+      </I18nextProvider>,
+      elem,
+    );
   }
   }
 });
 });
 
 
-// set page if exists
-if (componentInstances.page != null) {
-  crowi.setPage(componentInstances.page);
-}
-
-// render LikeButton
-const likeButtonElem = document.getElementById('like-button');
-if (likeButtonElem) {
-  const isLiked = likeButtonElem.dataset.liked === 'true';
-  ReactDOM.render(
-    <LikeButton crowi={crowi} pageId={pageId} isLiked={isLiked} />,
-    likeButtonElem,
-  );
-}
-
-// render UserPictureList for seen-user-list
-const seenUserListElem = document.getElementById('seen-user-list');
-if (seenUserListElem) {
-  const userIdsStr = seenUserListElem.dataset.userIds;
-  const userIds = userIdsStr.split(',');
-  ReactDOM.render(
-    <UserPictureList crowi={crowi} userIds={userIds} />,
-    seenUserListElem,
-  );
-}
-// render UserPictureList for liker-list
-const likerListElem = document.getElementById('liker-list');
-if (likerListElem) {
-  const userIdsStr = likerListElem.dataset.userIds;
-  const userIds = userIdsStr.split(',');
-  ReactDOM.render(
-    <UserPictureList crowi={crowi} userIds={userIds} />,
-    likerListElem,
-  );
-}
-
-// render SavePageControls
-let savePageControls = null;
-const savePageControlsElem = document.getElementById('save-page-controls');
-if (savePageControlsElem) {
-  const grant = +savePageControlsElem.dataset.grant;
-  const grantGroupId = savePageControlsElem.dataset.grantGroup;
-  const grantGroupName = savePageControlsElem.dataset.grantGroupName;
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <SavePageControls
-        crowi={crowi}
-        onSubmit={saveWithSubmitButton}
-        ref={(elem) => {
-            if (savePageControls == null) {
-              savePageControls = elem;
-            }
-          }}
-        pageId={pageId}
-        slackChannels={slackChannels}
-        grant={grant}
-        grantGroupId={grantGroupId}
-        grantGroupName={grantGroupName}
-      />
-    </I18nextProvider>,
-    savePageControlsElem,
-  );
-  componentInstances.savePageControls = savePageControls;
-}
-
-const recentCreatedControlsElem = document.getElementById('user-created-list');
-if (recentCreatedControlsElem) {
-  let limit = crowi.getConfig().recentCreatedLimit;
-  if (limit == null) {
-    limit = 10;
-  }
-  ReactDOM.render(
-    <RecentCreated crowi={crowi} pageId={pageId} limit={limit}>
-
-    </RecentCreated>, document.getElementById('user-created-list'),
-  );
-}
-
-const myDraftControlsElem = document.getElementById('user-draft-list');
-if (myDraftControlsElem) {
-  let limit = crowi.getConfig().recentCreatedLimit;
-  if (limit == null) {
-    limit = 10;
-  }
-
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <MyDraftList
-        limit={limit}
-        crowi={crowi}
-        crowiOriginRenderer={crowiRenderer}
-      />
-    </I18nextProvider>,
-    myDraftControlsElem,
-  );
-}
-
-/*
- * HackMD Editor
- */
-// render PageEditorWithHackmd
-let pageEditorByHackmd = null;
-const pageEditorWithHackmdElem = document.getElementById('page-editor-with-hackmd');
-if (pageEditorWithHackmdElem) {
-  pageEditorByHackmd = ReactDOM.render(
-    <PageEditorByHackmd
-      crowi={crowi}
-      pageId={pageId}
-      revisionId={pageRevisionId}
-      pageIdOnHackmd={pageIdOnHackmd}
-      revisionIdHackmdSynced={pageRevisionIdHackmdSynced}
-      hasDraftOnHackmd={hasDraftOnHackmd}
-      markdown={markdown}
-      onSaveWithShortcut={saveWithShortcut}
-    />,
-    pageEditorWithHackmdElem,
-  );
-  componentInstances.pageEditorByHackmd = pageEditorByHackmd;
-}
-
-
-/*
- * PageEditor
- */
-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) {
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <PageEditor
-        ref={(elem) => {
-          if (pageEditor == null) {
-            pageEditor = elem;
-          }
-        }}
-        crowi={crowi}
-        crowiRenderer={crowiRenderer}
-        pageId={pageId}
-        revisionId={pageRevisionId}
-        pagePath={pagePath}
-        markdown={markdown}
-        editorOptions={editorOptions}
-        previewOptions={previewOptions}
-        onSaveWithShortcut={saveWithShortcut}
-      />
-    </I18nextProvider>,
-    pageEditorElem,
-  );
-  componentInstances.pageEditor = pageEditor;
-  // set refs for setCaretLine/forceToFocus when tab is changed
-  crowi.setPageEditor(pageEditor);
-}
-
-// render comment form
-const writeCommentElem = document.getElementById('page-comment-write');
-if (writeCommentElem) {
-  ReactDOM.render(
-    <Provider inject={[commentContainer]}>
-      <I18nextProvider i18n={i18n}>
-        <CommentEditorLazyRenderer
-          crowi={crowi}
-          crowiOriginRenderer={crowiRenderer}
-          editorOptions={pageEditorOptions}
-          slackChannels={slackChannels}
-        >
-        </CommentEditorLazyRenderer>
-      </I18nextProvider>
-    </Provider>,
-    writeCommentElem,
-  );
-}
-
-// render OptionsSelector
-const pageEditorOptionsSelectorElem = document.getElementById('page-editor-options-selector');
-if (pageEditorOptionsSelectorElem) {
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <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);
-        }}
-      />
-    </I18nextProvider>,
-    pageEditorOptionsSelectorElem,
-  );
-}
-
-// render PageStatusAlert
-let pageStatusAlert = null;
-const pageStatusAlertElem = document.getElementById('page-status-alert');
-if (pageStatusAlertElem) {
-  ReactDOM.render(
-    <I18nextProvider i18n={i18n}>
-      <PageStatusAlert
-        ref={(elem) => {
-            if (pageStatusAlert == null) {
-              pageStatusAlert = elem;
-            }
-          }}
-        revisionId={pageRevisionId}
-        revisionIdHackmdSynced={pageRevisionIdHackmdSynced}
-        hasDraftOnHackmd={hasDraftOnHackmd}
-      />
-    </I18nextProvider>,
-    pageStatusAlertElem,
-  );
-  componentInstances.pageStatusAlert = pageStatusAlert;
-}
-
 // render for admin
 // render for admin
 const customCssEditorElem = document.getElementById('custom-css-editor');
 const customCssEditorElem = document.getElementById('custom-css-editor');
 if (customCssEditorElem != null) {
 if (customCssEditorElem != null) {
@@ -609,7 +323,7 @@ if (customHeaderEditorElem != null) {
 const adminRebuildSearchElem = document.getElementById('admin-rebuild-search');
 const adminRebuildSearchElem = document.getElementById('admin-rebuild-search');
 if (adminRebuildSearchElem != null) {
 if (adminRebuildSearchElem != null) {
   ReactDOM.render(
   ReactDOM.render(
-    <AdminRebuildSearch crowi={crowi} />,
+    <AdminRebuildSearch crowi={appContainer} />,
     adminRebuildSearchElem,
     adminRebuildSearchElem,
   );
   );
 }
 }
@@ -618,96 +332,18 @@ if (adminGrantSelectorElem != null) {
   ReactDOM.render(
   ReactDOM.render(
     <I18nextProvider i18n={i18n}>
     <I18nextProvider i18n={i18n}>
       <GroupDeleteModal
       <GroupDeleteModal
-        crowi={crowi}
+        crowi={appContainer}
       />
       />
     </I18nextProvider>,
     </I18nextProvider>,
     adminGrantSelectorElem,
     adminGrantSelectorElem,
   );
   );
 }
 }
 
 
-// notification from websocket
-function updatePageStatusAlert(page, user) {
-  const pageStatusAlert = componentInstances.pageStatusAlert;
-  if (pageStatusAlert != null) {
-    const revisionId = page.revision._id;
-    const revisionIdHackmdSynced = page.revisionHackmdSynced;
-    pageStatusAlert.setRevisionId(revisionId, revisionIdHackmdSynced);
-    pageStatusAlert.setLastUpdateUsername(user.name);
-  }
-}
-socket.on('page:create', (data) => {
-  // skip if triggered myself
-  if (data.socketClientId != null && data.socketClientId === socketClientId) {
-    return;
-  }
-
-  logger.debug({ obj: data }, `websocket on 'page:create'`); // eslint-disable-line quotes
-
-  // update PageStatusAlert
-  if (data.page.path === pagePath) {
-    updatePageStatusAlert(data.page, data.user);
-  }
-});
-socket.on('page:update', (data) => {
-  // skip if triggered myself
-  if (data.socketClientId != null && data.socketClientId === socketClientId) {
-    return;
-  }
-
-  logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
-
-  if (data.page.path === pagePath) {
-    // update PageStatusAlert
-    updatePageStatusAlert(data.page, data.user);
-    // update PageEditorByHackmd
-    const pageEditorByHackmd = componentInstances.pageEditorByHackmd;
-    if (pageEditorByHackmd != null) {
-      const page = data.page;
-      pageEditorByHackmd.setRevisionId(page.revision._id, page.revisionHackmdSynced);
-      pageEditorByHackmd.setHasDraftOnHackmd(data.page.hasDraftOnHackmd);
-    }
-  }
-});
-socket.on('page:delete', (data) => {
-  // skip if triggered myself
-  if (data.socketClientId != null && data.socketClientId === socketClientId) {
-    return;
-  }
-
-  logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes
-
-  // update PageStatusAlert
-  if (data.page.path === pagePath) {
-    updatePageStatusAlert(data.page, data.user);
-  }
-});
-socket.on('page:editingWithHackmd', (data) => {
-  // skip if triggered myself
-  if (data.socketClientId != null && data.socketClientId === socketClientId) {
-    return;
-  }
-
-  logger.debug({ obj: data }, `websocket on 'page:editingWithHackmd'`); // eslint-disable-line quotes
-
-  if (data.page.path === pagePath) {
-    // update PageStatusAlert
-    const pageStatusAlert = componentInstances.pageStatusAlert;
-    if (pageStatusAlert != null) {
-      pageStatusAlert.setHasDraftOnHackmd(data.page.hasDraftOnHackmd);
-    }
-    // update PageEditorByHackmd
-    const pageEditorByHackmd = componentInstances.pageEditorByHackmd;
-    if (pageEditorByHackmd != null) {
-      pageEditorByHackmd.setHasDraftOnHackmd(data.page.hasDraftOnHackmd);
-    }
-  }
-});
-
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
   ReactDOM.render(
   ReactDOM.render(
     <I18nextProvider i18n={i18n}>
     <I18nextProvider i18n={i18n}>
-      <PageHistory pageId={pageId} crowi={crowi} />
+      <PageHistory pageId={pageContainer.state.pageId} crowi={appContainer} />
     </I18nextProvider>, document.getElementById('revision-history'),
     </I18nextProvider>, document.getElementById('revision-history'),
   );
   );
 });
 });

+ 19 - 5
src/client/js/components/LikeButton.jsx

@@ -1,7 +1,10 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-export default class LikeButton extends React.Component {
+import { createSubscribedElement } from './UnstatedUtils';
+import AppContainer from '../services/AppContainer';
+
+class LikeButton extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -16,16 +19,17 @@ export default class LikeButton extends React.Component {
   handleClick(event) {
   handleClick(event) {
     event.preventDefault();
     event.preventDefault();
 
 
+    const { appContainer } = this.props;
     const pageId = this.props.pageId;
     const pageId = this.props.pageId;
 
 
     if (!this.state.isLiked) {
     if (!this.state.isLiked) {
-      this.props.crowi.apiPost('/likes.add', { page_id: pageId })
+      appContainer.apiPost('/likes.add', { page_id: pageId })
         .then((res) => {
         .then((res) => {
           this.setState({ isLiked: true });
           this.setState({ isLiked: true });
         });
         });
     }
     }
     else {
     else {
-      this.props.crowi.apiPost('/likes.remove', { page_id: pageId })
+      appContainer.apiPost('/likes.remove', { page_id: pageId })
         .then((res) => {
         .then((res) => {
           this.setState({ isLiked: false });
           this.setState({ isLiked: false });
         });
         });
@@ -33,7 +37,7 @@ export default class LikeButton extends React.Component {
   }
   }
 
 
   isUserLoggedIn() {
   isUserLoggedIn() {
-    return this.props.crowi.me !== '';
+    return this.props.appContainer.me !== '';
   }
   }
 
 
   render() {
   render() {
@@ -64,9 +68,19 @@ export default class LikeButton extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const LikeButtonWrapper = (props) => {
+  return createSubscribedElement(LikeButton, props, [AppContainer]);
+};
+
 LikeButton.propTypes = {
 LikeButton.propTypes = {
-  crowi: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   pageId: PropTypes.string,
   pageId: PropTypes.string,
   isLiked: PropTypes.bool,
   isLiked: PropTypes.bool,
   size: PropTypes.string,
   size: PropTypes.string,
 };
 };
+
+export default LikeButtonWrapper;

+ 27 - 10
src/client/js/components/MyDraftList/MyDraftList.jsx

@@ -1,10 +1,15 @@
 import React from 'react';
 import React from 'react';
-
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+
 import Pagination from 'react-bootstrap/lib/Pagination';
 import Pagination from 'react-bootstrap/lib/Pagination';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+
 import Draft from '../PageList/Draft';
 import Draft from '../PageList/Draft';
 
 
-export default class MyDraftList extends React.Component {
+class MyDraftList extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -29,9 +34,9 @@ export default class MyDraftList extends React.Component {
   }
   }
 
 
   async getDraftsFromLocalStorage() {
   async getDraftsFromLocalStorage() {
-    const draftsAsObj = JSON.parse(this.props.crowi.localStorage.getItem('draft') || '{}');
+    const draftsAsObj = this.props.pageContainer.drafts;
 
 
-    const res = await this.props.crowi.apiGet('/pages.exist', {
+    const res = await this.props.appContainer.apiGet('/pages.exist', {
       pages: draftsAsObj,
       pages: draftsAsObj,
     });
     });
 
 
@@ -49,7 +54,10 @@ export default class MyDraftList extends React.Component {
   }
   }
 
 
   getCurrentDrafts(selectPageNumber) {
   getCurrentDrafts(selectPageNumber) {
-    const limit = this.props.limit;
+    const { appContainer } = this.props;
+
+    const limit = appContainer.getConfig().recentCreatedLimit;
+
     const totalCount = this.state.drafts.length;
     const totalCount = this.state.drafts.length;
     const activePage = selectPageNumber;
     const activePage = selectPageNumber;
     const paginationNumbers = this.calculatePagination(limit, totalCount, activePage);
     const paginationNumbers = this.calculatePagination(limit, totalCount, activePage);
@@ -74,7 +82,6 @@ export default class MyDraftList extends React.Component {
       return (
       return (
         <Draft
         <Draft
           key={draft.path}
           key={draft.path}
-          crowi={this.props.crowi}
           crowiOriginRenderer={this.props.crowiOriginRenderer}
           crowiOriginRenderer={this.props.crowiOriginRenderer}
           path={draft.path}
           path={draft.path}
           markdown={draft.markdown}
           markdown={draft.markdown}
@@ -86,7 +93,7 @@ export default class MyDraftList extends React.Component {
   }
   }
 
 
   clearDraft(path) {
   clearDraft(path) {
-    this.props.crowi.clearDraft(path);
+    this.props.pageContainer.clearDraft(path);
 
 
     this.setState((prevState) => {
     this.setState((prevState) => {
       return {
       return {
@@ -97,7 +104,7 @@ export default class MyDraftList extends React.Component {
   }
   }
 
 
   clearAllDrafts() {
   clearAllDrafts() {
-    this.props.crowi.clearAllDrafts();
+    this.props.pageContainer.clearAllDrafts();
 
 
     this.setState({
     this.setState({
       drafts: [],
       drafts: [],
@@ -244,9 +251,19 @@ export default class MyDraftList extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const MyDraftListWrapper = (props) => {
+  return createSubscribedElement(MyDraftList, props, [AppContainer, PageContainer]);
+};
+
 
 
 MyDraftList.propTypes = {
 MyDraftList.propTypes = {
-  limit: PropTypes.number,
-  crowi: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
   crowiOriginRenderer: PropTypes.object.isRequired,
   crowiOriginRenderer: PropTypes.object.isRequired,
 };
 };
+
+export default MyDraftListWrapper;

+ 28 - 20
src/client/js/components/Page.jsx

@@ -1,26 +1,28 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
+import { createSubscribedElement } from './UnstatedUtils';
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+
+import MarkdownTable from '../models/MarkdownTable';
+
 import RevisionRenderer from './Page/RevisionRenderer';
 import RevisionRenderer from './Page/RevisionRenderer';
 import HandsontableModal from './PageEditor/HandsontableModal';
 import HandsontableModal from './PageEditor/HandsontableModal';
-import MarkdownTable from '../models/MarkdownTable';
 import mtu from './PageEditor/MarkdownTableUtil';
 import mtu from './PageEditor/MarkdownTableUtil';
 
 
-export default class Page extends React.Component {
+class Page extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
     this.state = {
     this.state = {
-      markdown: this.props.markdown,
       currentTargetTableArea: null,
       currentTargetTableArea: null,
     };
     };
 
 
-    this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
-  }
+    this.growiRenderer = this.props.appContainer.getRenderer('page');
 
 
-  setMarkdown(markdown) {
-    this.setState({ markdown });
+    this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
   }
   }
 
 
   /**
   /**
@@ -29,7 +31,8 @@ export default class Page extends React.Component {
    * @param endLineNumber
    * @param endLineNumber
    */
    */
   launchHandsontableModal(beginLineNumber, endLineNumber) {
   launchHandsontableModal(beginLineNumber, endLineNumber) {
-    const tableLines = this.state.markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber).join('\n');
+    const markdown = this.props.pageContainer.state.markdown;
+    const tableLines = markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber).join('\n');
     this.setState({ currentTargetTableArea: { beginLineNumber, endLineNumber } });
     this.setState({ currentTargetTableArea: { beginLineNumber, endLineNumber } });
     this.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
     this.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
   }
   }
@@ -37,7 +40,7 @@ export default class Page extends React.Component {
   saveHandlerForHandsontableModal(markdownTable) {
   saveHandlerForHandsontableModal(markdownTable) {
     const newMarkdown = mtu.replaceMarkdownTableInMarkdown(
     const newMarkdown = mtu.replaceMarkdownTableInMarkdown(
       markdownTable,
       markdownTable,
-      this.state.markdown,
+      this.props.pageContainer.state.markdown,
       this.state.currentTargetTableArea.beginLineNumber,
       this.state.currentTargetTableArea.beginLineNumber,
       this.state.currentTargetTableArea.endLineNumber,
       this.state.currentTargetTableArea.endLineNumber,
     );
     );
@@ -46,16 +49,12 @@ export default class Page extends React.Component {
   }
   }
 
 
   render() {
   render() {
-    const isMobile = this.props.crowi.isMobile;
+    const isMobile = this.props.appContainer.isMobile;
+    const { markdown } = this.props.pageContainer.state;
 
 
     return (
     return (
       <div className={isMobile ? 'page-mobile' : ''}>
       <div className={isMobile ? 'page-mobile' : ''}>
-        <RevisionRenderer
-          crowi={this.props.crowi}
-          crowiRenderer={this.props.crowiRenderer}
-          markdown={this.state.markdown}
-          pagePath={this.props.pagePath}
-        />
+        <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
         <HandsontableModal ref={(c) => { this.handsontableModal = c }} onSave={this.saveHandlerForHandsontableModal} />
         <HandsontableModal ref={(c) => { this.handsontableModal = c }} onSave={this.saveHandlerForHandsontableModal} />
       </div>
       </div>
     );
     );
@@ -63,10 +62,19 @@ export default class Page extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const PageWrapper = (props) => {
+  return createSubscribedElement(Page, props, [AppContainer, PageContainer]);
+};
+
+
 Page.propTypes = {
 Page.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
   onSaveWithShortcut: PropTypes.func.isRequired,
   onSaveWithShortcut: PropTypes.func.isRequired,
-  markdown: PropTypes.string.isRequired,
-  pagePath: PropTypes.string.isRequired,
 };
 };
+
+export default PageWrapper;

+ 19 - 6
src/client/js/components/Page/RevisionLoader.jsx

@@ -3,12 +3,16 @@ import PropTypes from 'prop-types';
 
 
 import { Waypoint } from 'react-waypoint';
 import { Waypoint } from 'react-waypoint';
 
 
+import { createSubscribedElement } from '../UnstatedUtils';
+import GrowiRenderer from '../../util/GrowiRenderer';
+import AppContainer from '../../services/AppContainer';
+
 import RevisionRenderer from './RevisionRenderer';
 import RevisionRenderer from './RevisionRenderer';
 
 
 /**
 /**
  * Load data from server and render RevisionBody component
  * Load data from server and render RevisionBody component
  */
  */
-export default class RevisionLoader extends React.Component {
+class RevisionLoader extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -42,7 +46,7 @@ export default class RevisionLoader extends React.Component {
     };
     };
 
 
     // load data with REST API
     // load data with REST API
-    this.props.crowi.apiGet('/revisions.get', requestData)
+    this.props.appContainer.apiGet('/revisions.get', requestData)
       .then((res) => {
       .then((res) => {
         if (!res.ok) {
         if (!res.ok) {
           throw new Error(res.error);
           throw new Error(res.error);
@@ -96,8 +100,7 @@ export default class RevisionLoader extends React.Component {
 
 
     return (
     return (
       <RevisionRenderer
       <RevisionRenderer
-        crowi={this.props.crowi}
-        crowiRenderer={this.props.crowiRenderer}
+        growiRenderer={this.props.growiRenderer}
         pagePath={this.props.pagePath}
         pagePath={this.props.pagePath}
         markdown={markdown}
         markdown={markdown}
         highlightKeywords={this.props.highlightKeywords}
         highlightKeywords={this.props.highlightKeywords}
@@ -107,12 +110,22 @@ export default class RevisionLoader extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const RevisionLoaderWrapper = (props) => {
+  return createSubscribedElement(RevisionLoader, props, [AppContainer]);
+};
+
 RevisionLoader.propTypes = {
 RevisionLoader.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   pageId: PropTypes.string.isRequired,
   pageId: PropTypes.string.isRequired,
   pagePath: PropTypes.string.isRequired,
   pagePath: PropTypes.string.isRequired,
   revisionId: PropTypes.string.isRequired,
   revisionId: PropTypes.string.isRequired,
   lazy: PropTypes.bool,
   lazy: PropTypes.bool,
   highlightKeywords: PropTypes.string,
   highlightKeywords: PropTypes.string,
 };
 };
+
+export default RevisionLoaderWrapper;

+ 34 - 19
src/client/js/components/Page/RevisionRenderer.jsx

@@ -1,9 +1,14 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+import GrowiRenderer from '../../util/GrowiRenderer';
+
 import RevisionBody from './RevisionBody';
 import RevisionBody from './RevisionBody';
 
 
-export default class RevisionRenderer extends React.Component {
+class RevisionRenderer extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -14,18 +19,16 @@ export default class RevisionRenderer extends React.Component {
 
 
     this.renderHtml = this.renderHtml.bind(this);
     this.renderHtml = this.renderHtml.bind(this);
     this.getHighlightedBody = this.getHighlightedBody.bind(this);
     this.getHighlightedBody = this.getHighlightedBody.bind(this);
+  }
 
 
-    this.setMarkdown(this.props.markdown);
+  componentWillMount() {
+    this.renderHtml(this.props.markdown, this.props.highlightKeywords);
   }
   }
 
 
   componentWillReceiveProps(nextProps) {
   componentWillReceiveProps(nextProps) {
     this.renderHtml(nextProps.markdown, this.props.highlightKeywords);
     this.renderHtml(nextProps.markdown, this.props.highlightKeywords);
   }
   }
 
 
-  setMarkdown(markdown) {
-    this.renderHtml(markdown, this.props.highlightKeywords);
-  }
-
   /**
   /**
    * transplanted from legacy code -- Yuki Takei
    * transplanted from legacy code -- Yuki Takei
    * @param {string} body html strings
    * @param {string} body html strings
@@ -48,30 +51,32 @@ export default class RevisionRenderer extends React.Component {
     return returnBody;
     return returnBody;
   }
   }
 
 
-  renderHtml(markdown, highlightKeywords) {
+  renderHtml(markdown) {
+    const { pageContainer } = this.props;
+
     const context = {
     const context = {
       markdown,
       markdown,
-      currentPagePath: this.props.pagePath,
+      currentPagePath: pageContainer.state.path,
     };
     };
 
 
-    const crowiRenderer = this.props.crowiRenderer;
-    const interceptorManager = this.props.crowi.interceptorManager;
+    const growiRenderer = this.props.growiRenderer;
+    const interceptorManager = this.props.appContainer.interceptorManager;
     interceptorManager.process('preRender', context)
     interceptorManager.process('preRender', context)
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {
       .then(() => {
-        context.markdown = crowiRenderer.preProcess(context.markdown);
+        context.markdown = growiRenderer.preProcess(context.markdown);
       })
       })
       .then(() => { return interceptorManager.process('postPreProcess', context) })
       .then(() => { return interceptorManager.process('postPreProcess', context) })
       .then(() => {
       .then(() => {
-        context.parsedHTML = crowiRenderer.process(context.markdown);
+        context.parsedHTML = growiRenderer.process(context.markdown);
       })
       })
       .then(() => { return interceptorManager.process('prePostProcess', context) })
       .then(() => { return interceptorManager.process('prePostProcess', context) })
       .then(() => {
       .then(() => {
-        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML);
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
 
 
         // highlight
         // highlight
-        if (highlightKeywords != null) {
-          context.parsedHTML = this.getHighlightedBody(context.parsedHTML, highlightKeywords);
+        if (this.props.highlightKeywords != null) {
+          context.parsedHTML = this.getHighlightedBody(context.parsedHTML, this.props.highlightKeywords);
         }
         }
       })
       })
       .then(() => { return interceptorManager.process('postPostProcess', context) })
       .then(() => { return interceptorManager.process('postPostProcess', context) })
@@ -85,7 +90,7 @@ export default class RevisionRenderer extends React.Component {
   }
   }
 
 
   render() {
   render() {
-    const config = this.props.crowi.getConfig();
+    const config = this.props.appContainer.getConfig();
     const isMathJaxEnabled = !!config.env.MATHJAX;
     const isMathJaxEnabled = !!config.env.MATHJAX;
 
 
     return (
     return (
@@ -99,10 +104,20 @@ export default class RevisionRenderer extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const RevisionRendererWrapper = (props) => {
+  return createSubscribedElement(RevisionRenderer, props, [AppContainer, PageContainer]);
+};
+
 RevisionRenderer.propTypes = {
 RevisionRenderer.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
+  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   markdown: PropTypes.string.isRequired,
   markdown: PropTypes.string.isRequired,
-  pagePath: PropTypes.string.isRequired,
   highlightKeywords: PropTypes.string,
   highlightKeywords: PropTypes.string,
 };
 };
+
+export default RevisionRendererWrapper;

+ 10 - 49
src/client/js/components/Page/TagEditor.jsx

@@ -1,11 +1,13 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import * as toastr from 'toastr';
 import Button from 'react-bootstrap/es/Button';
 import Button from 'react-bootstrap/es/Button';
 import Modal from 'react-bootstrap/es/Modal';
 import Modal from 'react-bootstrap/es/Modal';
+
+import AppContainer from '../../services/AppContainer';
+
 import TagsInput from './TagsInput';
 import TagsInput from './TagsInput';
 
 
-class TagEditor extends React.Component {
+export default class TagEditor extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -13,23 +15,19 @@ class TagEditor extends React.Component {
     this.state = {
     this.state = {
       tags: [],
       tags: [],
       isOpenModal: false,
       isOpenModal: false,
-      isEditorMode: null,
     };
     };
 
 
     this.show = this.show.bind(this);
     this.show = this.show.bind(this);
-    this.onTagsUpdatedByFormHandler = this.onTagsUpdatedByFormHandler.bind(this);
+    this.onTagsUpdatedByTagsInput = this.onTagsUpdatedByTagsInput.bind(this);
     this.closeModalHandler = this.closeModalHandler.bind(this);
     this.closeModalHandler = this.closeModalHandler.bind(this);
     this.handleSubmit = this.handleSubmit.bind(this);
     this.handleSubmit = this.handleSubmit.bind(this);
-    this.apiSuccessHandler = this.apiSuccessHandler.bind(this);
-    this.apiErrorHandler = this.apiErrorHandler.bind(this);
   }
   }
 
 
   show(tags) {
   show(tags) {
-    const isEditorMode = this.props.crowi.getCrowiForJquery().getCurrentEditorMode();
-    this.setState({ isOpenModal: true, isEditorMode, tags });
+    this.setState({ tags, isOpenModal: true });
   }
   }
 
 
-  onTagsUpdatedByFormHandler(tags) {
+  onTagsUpdatedByTagsInput(tags) {
     this.setState({ tags });
     this.setState({ tags });
   }
   }
 
 
@@ -38,47 +36,12 @@ class TagEditor extends React.Component {
   }
   }
 
 
   async handleSubmit() {
   async handleSubmit() {
-
-    if (!this.state.isEditorMode) {
-      try {
-        await this.props.crowi.apiPost('/tags.update', { pageId: this.props.pageId, tags: this.state.tags });
-        this.apiSuccessHandler();
-      }
-      catch (err) {
-        this.apiErrorHandler(err);
-        return;
-      }
-    }
-
     this.props.onTagsUpdated(this.state.tags);
     this.props.onTagsUpdated(this.state.tags);
 
 
     // close modal
     // close modal
     this.setState({ isOpenModal: false });
     this.setState({ isOpenModal: false });
   }
   }
 
 
-  apiSuccessHandler() {
-    toastr.success(undefined, 'updated tags successfully', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '1200',
-      extendedTimeOut: '150',
-    });
-  }
-
-  apiErrorHandler(err) {
-    toastr.error(err.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
-  }
-
   render() {
   render() {
     return (
     return (
       <Modal show={this.state.isOpenModal} onHide={this.closeModalHandler} id="editTagModal">
       <Modal show={this.state.isOpenModal} onHide={this.closeModalHandler} id="editTagModal">
@@ -86,7 +49,7 @@ class TagEditor extends React.Component {
           <Modal.Title className="text-white">Edit Tags</Modal.Title>
           <Modal.Title className="text-white">Edit Tags</Modal.Title>
         </Modal.Header>
         </Modal.Header>
         <Modal.Body>
         <Modal.Body>
-          <TagsInput crowi={this.props.crowi} tags={this.state.tags} onTagsUpdated={this.onTagsUpdatedByFormHandler} />
+          <TagsInput tags={this.state.tags} onTagsUpdated={this.onTagsUpdatedByTagsInput} />
         </Modal.Body>
         </Modal.Body>
         <Modal.Footer>
         <Modal.Footer>
           <Button variant="primary" onClick={this.handleSubmit}>
           <Button variant="primary" onClick={this.handleSubmit}>
@@ -100,9 +63,7 @@ class TagEditor extends React.Component {
 }
 }
 
 
 TagEditor.propTypes = {
 TagEditor.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  pageId: PropTypes.string,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   onTagsUpdated: PropTypes.func.isRequired,
   onTagsUpdated: PropTypes.func.isRequired,
 };
 };
-
-export default TagEditor;

+ 94 - 36
src/client/js/components/Page/TagLabels.jsx

@@ -2,6 +2,13 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
+import * as toastr from 'toastr';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+import EditorContainer from '../../services/EditorContainer';
+
 import TagEditor from './TagEditor';
 import TagEditor from './TagEditor';
 
 
 class TagLabels extends React.Component {
 class TagLabels extends React.Component {
@@ -10,61 +17,105 @@ class TagLabels extends React.Component {
     super(props);
     super(props);
 
 
     this.state = {
     this.state = {
-      tags: [],
+      showTagEditor: false,
     };
     };
 
 
     this.showEditor = this.showEditor.bind(this);
     this.showEditor = this.showEditor.bind(this);
     this.tagsUpdatedHandler = this.tagsUpdatedHandler.bind(this);
     this.tagsUpdatedHandler = this.tagsUpdatedHandler.bind(this);
   }
   }
 
 
-  async componentWillMount() {
-    // set pageTag on button
-    const pageId = this.props.pageId;
+  /**
+   * @return tags data
+   *   1. pageContainer.state.tags if editorMode is null
+   *   2. editorContainer.state.tags if editorMode is not null
+   */
+  getEditTargetData() {
+    const { editorMode } = this.props.appContainer.state;
+    return (editorMode == null)
+      ? this.props.pageContainer.state.tags
+      : this.props.editorContainer.state.tags;
+  }
+
+  showEditor() {
+    this.tagEditor.show(this.getEditTargetData());
+  }
+
+  async tagsUpdatedHandler(tags) {
+    const { appContainer, editorContainer } = this.props;
+    const { editorMode } = appContainer.state;
+
+    // post api request and update tags
+    if (editorMode == null) {
+      const { pageContainer } = this.props;
+
+      try {
+        const { pageId } = pageContainer.state;
+        await appContainer.apiPost('/tags.update', { pageId, tags });
 
 
-    if (pageId) {
-      const res = await this.props.crowi.apiGet('/pages.getPageTag', { pageId });
-      this.setState({ tags: res.tags });
-      this.props.sendTagData(res.tags);
+        // update pageContainer.state
+        pageContainer.setState({ tags });
+        editorContainer.setState({ tags });
+
+        this.apiSuccessHandler();
+      }
+      catch (err) {
+        this.apiErrorHandler(err);
+        return;
+      }
     }
     }
-    else if (this.props.templateTagData) {
-      const templateTags = this.props.templateTagData.split(',');
-      this.setState({ tags: templateTags });
-      this.props.sendTagData(templateTags);
+    // only update tags in editorContainer
+    else {
+      editorContainer.setState({ tags });
     }
     }
   }
   }
 
 
-  showEditor() {
-    this.tagEditor.show(this.state.tags);
+  apiSuccessHandler() {
+    toastr.success(undefined, 'updated tags successfully', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '1200',
+      extendedTimeOut: '150',
+    });
   }
   }
 
 
-  tagsUpdatedHandler(tags) {
-    this.setState({ tags });
-    this.props.sendTagData(tags);
+  apiErrorHandler(err) {
+    toastr.error(err.message, 'Error occured', {
+      closeButton: true,
+      progressBar: true,
+      newestOnTop: false,
+      showDuration: '100',
+      hideDuration: '100',
+      timeOut: '3000',
+    });
   }
   }
 
 
   render() {
   render() {
-    const tagElements = [];
-    const { t, pageId } = this.props;
+    const { t } = this.props;
+    const { pageId } = this.props.pageContainer.state;
 
 
-    for (let i = 0; i < this.state.tags.length; i++) {
-      tagElements.push(
-        <span key={`${pageId}_${i}`} className="text-muted">
+    const tags = this.getEditTargetData();
+
+    const tagElements = tags.map((tag) => {
+      return (
+        <span key={`${pageId}_${tag}`} className="text-muted">
           <i className="tag-icon icon-tag mr-1"></i>
           <i className="tag-icon icon-tag mr-1"></i>
-          <a className="tag-name mr-2" href={`/_search?q=tag:${this.state.tags[i]}`} key={i.toString()}>{this.state.tags[i]}</a>
-        </span>,
+          <a className="tag-name mr-2" href={`/_search?q=tag:${tag}`} key={`${pageId}_${tag}_link`}>{tag}</a>
+        </span>
       );
       );
-
-    }
+    });
 
 
     return (
     return (
-      <div className={`tag-viewer ${this.props.pageId ? 'existed-page' : 'new-page'}`}>
-        {this.state.tags.length === 0 && (
+      <div className={`tag-viewer ${pageId ? 'existed-page' : 'new-page'}`}>
+        {tags.length === 0 && (
           <a className="btn btn-link btn-edit-tags no-tags p-0" onClick={this.showEditor}>
           <a className="btn btn-link btn-edit-tags no-tags p-0" onClick={this.showEditor}>
             { t('Add tags for this page') } <i className="manage-tags ml-2 icon-plus"></i>
             { t('Add tags for this page') } <i className="manage-tags ml-2 icon-plus"></i>
           </a>
           </a>
         )}
         )}
         {tagElements}
         {tagElements}
-        {this.state.tags.length > 0 && (
+        {tags.length > 0 && (
           <a className="btn btn-link btn-edit-tags p-0" onClick={this.showEditor}>
           <a className="btn btn-link btn-edit-tags p-0" onClick={this.showEditor}>
             <i className="manage-tags ml-2 icon-plus"></i> { t('Edit tags for this page') }
             <i className="manage-tags ml-2 icon-plus"></i> { t('Edit tags for this page') }
           </a>
           </a>
@@ -72,8 +123,8 @@ class TagLabels extends React.Component {
 
 
         <TagEditor
         <TagEditor
           ref={(c) => { this.tagEditor = c }}
           ref={(c) => { this.tagEditor = c }}
-          crowi={this.props.crowi}
-          pageId={this.props.pageId}
+          appContainer={this.props.appContainer}
+          show={this.state.showTagEditor}
           onTagsUpdated={this.tagsUpdatedHandler}
           onTagsUpdated={this.tagsUpdatedHandler}
         >
         >
         </TagEditor>
         </TagEditor>
@@ -83,12 +134,19 @@ class TagLabels extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const TagLabelsWrapper = (props) => {
+  return createSubscribedElement(TagLabels, props, [AppContainer, PageContainer, EditorContainer]);
+};
+
+
 TagLabels.propTypes = {
 TagLabels.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
-  pageId: PropTypes.string,
-  sendTagData: PropTypes.func.isRequired,
-  templateTagData: PropTypes.string,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 };
 
 
-export default withTranslation()(TagLabels);
+export default withTranslation()(TagLabelsWrapper);

+ 16 - 4
src/client/js/components/Page/TagsInput.jsx

@@ -2,6 +2,9 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
 
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+
 /**
 /**
  *
  *
  * @author Yuki Takei <yuki@weseek.co.jp>
  * @author Yuki Takei <yuki@weseek.co.jp>
@@ -11,7 +14,7 @@ import { AsyncTypeahead } from 'react-bootstrap-typeahead';
  * @extends {React.Component}
  * @extends {React.Component}
  */
  */
 
 
-export default class TagsInput extends React.Component {
+class TagsInput extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -22,7 +25,6 @@ export default class TagsInput extends React.Component {
       selected: this.props.tags,
       selected: this.props.tags,
       defaultPageTags: this.props.tags,
       defaultPageTags: this.props.tags,
     };
     };
-    this.crowi = this.props.crowi;
 
 
     this.handleChange = this.handleChange.bind(this);
     this.handleChange = this.handleChange.bind(this);
     this.handleSearch = this.handleSearch.bind(this);
     this.handleSearch = this.handleSearch.bind(this);
@@ -42,7 +44,7 @@ export default class TagsInput extends React.Component {
 
 
   async handleSearch(query) {
   async handleSearch(query) {
     this.setState({ isLoading: true });
     this.setState({ isLoading: true });
-    const res = await this.crowi.apiGet('/tags.search', { q: query });
+    const res = await this.props.appContainer.apiGet('/tags.search', { q: query });
     res.tags.unshift(query); // selectable new tag whose name equals query
     res.tags.unshift(query); // selectable new tag whose name equals query
     this.setState({
     this.setState({
       resultTags: Array.from(new Set(res.tags)), // use Set for de-duplication
       resultTags: Array.from(new Set(res.tags)), // use Set for de-duplication
@@ -87,11 +89,21 @@ export default class TagsInput extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const TagsInputWrapper = (props) => {
+  return createSubscribedElement(TagsInput, props, [AppContainer]);
+};
+
 TagsInput.propTypes = {
 TagsInput.propTypes = {
-  crowi: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   tags: PropTypes.array.isRequired,
   tags: PropTypes.array.isRequired,
   onTagsUpdated: PropTypes.func.isRequired,
   onTagsUpdated: PropTypes.func.isRequired,
 };
 };
 
 
 TagsInput.defaultProps = {
 TagsInput.defaultProps = {
 };
 };
+
+export default TagsInputWrapper;

+ 23 - 9
src/client/js/components/PageAttachment.js

@@ -4,8 +4,11 @@ import PropTypes from 'prop-types';
 
 
 import PageAttachmentList from './PageAttachment/PageAttachmentList';
 import PageAttachmentList from './PageAttachment/PageAttachmentList';
 import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
 import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
+import { createSubscribedElement } from './UnstatedUtils';
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
 
 
-export default class PageAttachment extends React.Component {
+class PageAttachment extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -23,13 +26,13 @@ export default class PageAttachment extends React.Component {
   }
   }
 
 
   componentDidMount() {
   componentDidMount() {
-    const pageId = this.props.pageId;
+    const { pageId } = this.props.pageContainer.state;
 
 
     if (!pageId) {
     if (!pageId) {
       return;
       return;
     }
     }
 
 
-    this.props.crowi.apiGet('/attachments.list', { page_id: pageId })
+    this.props.appContainer.apiGet('/attachments.list', { page_id: pageId })
       .then((res) => {
       .then((res) => {
         const attachments = res.attachments;
         const attachments = res.attachments;
         const inUse = {};
         const inUse = {};
@@ -46,7 +49,9 @@ export default class PageAttachment extends React.Component {
   }
   }
 
 
   checkIfFileInUse(attachment) {
   checkIfFileInUse(attachment) {
-    if (this.props.markdown.match(attachment.filePathProxied)) {
+    const { markdown } = this.pageContainer.state;
+
+    if (markdown.match(attachment.filePathProxied)) {
       return true;
       return true;
     }
     }
     return false;
     return false;
@@ -64,7 +69,7 @@ export default class PageAttachment extends React.Component {
       deleting: true,
       deleting: true,
     });
     });
 
 
-    this.props.crowi.apiPost('/attachments.remove', { attachment_id: attachmentId })
+    this.props.appContainer.apiPost('/attachments.remove', { attachment_id: attachmentId })
       .then((res) => {
       .then((res) => {
         this.setState({
         this.setState({
           attachments: this.state.attachments.filter((at) => {
           attachments: this.state.attachments.filter((at) => {
@@ -84,7 +89,7 @@ export default class PageAttachment extends React.Component {
   }
   }
 
 
   isUserLoggedIn() {
   isUserLoggedIn() {
-    return this.props.crowi.me !== '';
+    return this.props.appContainer.me !== '';
   }
   }
 
 
   render() {
   render() {
@@ -133,8 +138,17 @@ export default class PageAttachment extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const PageAttachmentWrapper = (props) => {
+  return createSubscribedElement(PageAttachment, props, [AppContainer, PageContainer]);
+};
+
+
 PageAttachment.propTypes = {
 PageAttachment.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  markdown: PropTypes.string.isRequired,
-  pageId: PropTypes.string.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 };
+
+export default PageAttachmentWrapper;

+ 33 - 27
src/client/js/components/PageComment/Comment.jsx

@@ -3,8 +3,11 @@ import PropTypes from 'prop-types';
 
 
 import dateFnsFormat from 'date-fns/format';
 import dateFnsFormat from 'date-fns/format';
 
 
-import RevisionBody from '../Page/RevisionBody';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
 
 
+import { createSubscribedElement } from '../UnstatedUtils';
+import RevisionBody from '../Page/RevisionBody';
 import UserPicture from '../User/UserPicture';
 import UserPicture from '../User/UserPicture';
 import Username from '../User/Username';
 import Username from '../User/Username';
 
 
@@ -16,7 +19,7 @@ import Username from '../User/Username';
  * @class Comment
  * @class Comment
  * @extends {React.Component}
  * @extends {React.Component}
  */
  */
-export default class Comment extends React.Component {
+class Comment extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -41,7 +44,7 @@ export default class Comment extends React.Component {
   }
   }
 
 
   init() {
   init() {
-    const layoutType = this.props.crowi.getConfig().layoutType;
+    const layoutType = this.props.appContainer.getConfig().layoutType;
     this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
     this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
   }
   }
 
 
@@ -55,11 +58,11 @@ export default class Comment extends React.Component {
   }
   }
 
 
   isCurrentUserEqualsToAuthor() {
   isCurrentUserEqualsToAuthor() {
-    return this.props.comment.creator.username === this.props.crowi.me;
+    return this.props.comment.creator.username === this.props.appContainer.me;
   }
   }
 
 
   isCurrentRevision() {
   isCurrentRevision() {
-    return this.props.comment.revision === this.props.revisionId;
+    return this.props.comment.revision === this.props.pageContainer.state.revisionId;
   }
   }
 
 
   getRootClassName() {
   getRootClassName() {
@@ -81,7 +84,7 @@ export default class Comment extends React.Component {
   }
   }
 
 
   renderRevisionBody() {
   renderRevisionBody() {
-    const config = this.props.crowi.getConfig();
+    const config = this.props.appContainer.getConfig();
     const isMathJaxEnabled = !!config.env.MATHJAX;
     const isMathJaxEnabled = !!config.env.MATHJAX;
     return (
     return (
       <RevisionBody
       <RevisionBody
@@ -106,21 +109,21 @@ export default class Comment extends React.Component {
       markdown,
       markdown,
     };
     };
 
 
-    const crowiRenderer = this.props.crowiRenderer;
-    const interceptorManager = this.props.crowi.interceptorManager;
+    const growiRenderer = this.props.growiRenderer;
+    const interceptorManager = this.props.appContainer.interceptorManager;
     interceptorManager.process('preRenderComment', context)
     interceptorManager.process('preRenderComment', context)
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {
       .then(() => {
-        context.markdown = crowiRenderer.preProcess(context.markdown);
+        context.markdown = growiRenderer.preProcess(context.markdown);
       })
       })
       .then(() => { return interceptorManager.process('postPreProcess', context) })
       .then(() => { return interceptorManager.process('postPreProcess', context) })
       .then(() => {
       .then(() => {
-        const parsedHTML = crowiRenderer.process(context.markdown);
+        const parsedHTML = growiRenderer.process(context.markdown);
         context.parsedHTML = parsedHTML;
         context.parsedHTML = parsedHTML;
       })
       })
       .then(() => { return interceptorManager.process('prePostProcess', context) })
       .then(() => { return interceptorManager.process('prePostProcess', context) })
       .then(() => {
       .then(() => {
-        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML);
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML);
       })
       })
       .then(() => { return interceptorManager.process('postPostProcess', context) })
       .then(() => { return interceptorManager.process('postPostProcess', context) })
       .then(() => { return interceptorManager.process('preRenderCommentHtml', context) })
       .then(() => { return interceptorManager.process('preRenderCommentHtml', context) })
@@ -156,14 +159,11 @@ export default class Comment extends React.Component {
     const toggleElements = hiddenReplies.map((reply) => {
     const toggleElements = hiddenReplies.map((reply) => {
       return (
       return (
         <div key={reply._id} className="col-xs-offset-1 col-xs-11 col-sm-offset-1 col-sm-11 col-md-offset-1 col-md-11 col-lg-offset-1 col-lg-11">
         <div key={reply._id} className="col-xs-offset-1 col-xs-11 col-sm-offset-1 col-sm-11 col-md-offset-1 col-md-11 col-lg-offset-1 col-lg-11">
-          <Comment
+          <CommentWrapper
             comment={reply}
             comment={reply}
             deleteBtnClicked={this.props.deleteBtnClicked}
             deleteBtnClicked={this.props.deleteBtnClicked}
-            crowiRenderer={this.props.crowiRenderer}
-            crowi={this.props.crowi}
+            growiRenderer={this.props.growiRenderer}
             replyList={[]}
             replyList={[]}
-            revisionCreatedAt={this.props.revisionCreatedAt}
-            revisionId={this.props.revisionId}
           />
           />
         </div>
         </div>
       );
       );
@@ -178,14 +178,11 @@ export default class Comment extends React.Component {
     const shownBlock = shownReplies.map((reply) => {
     const shownBlock = shownReplies.map((reply) => {
       return (
       return (
         <div key={reply._id} className="col-xs-offset-1 col-xs-11 col-sm-offset-1 col-sm-11 col-md-offset-1 col-md-11 col-lg-offset-1 col-lg-11">
         <div key={reply._id} className="col-xs-offset-1 col-xs-11 col-sm-offset-1 col-sm-11 col-md-offset-1 col-md-11 col-lg-offset-1 col-lg-11">
-          <Comment
+          <CommentWrapper
             comment={reply}
             comment={reply}
             deleteBtnClicked={this.props.deleteBtnClicked}
             deleteBtnClicked={this.props.deleteBtnClicked}
-            crowiRenderer={this.props.crowiRenderer}
-            crowi={this.props.crowi}
+            growiRenderer={this.props.growiRenderer}
             replyList={[]}
             replyList={[]}
-            revisionCreatedAt={this.props.revisionCreatedAt}
-            revisionId={this.props.revisionId}
           />
           />
         </div>
         </div>
       );
       );
@@ -212,8 +209,8 @@ export default class Comment extends React.Component {
     const revFirst8Letters = comment.revision.substr(-8);
     const revFirst8Letters = comment.revision.substr(-8);
     const revisionLavelClassName = this.getRevisionLabelClassName();
     const revisionLavelClassName = this.getRevisionLabelClassName();
 
 
-    const revisionId = this.props.revisionId;
-    const revisionCreatedAt = this.props.revisionCreatedAt;
+    const { revisionId, revisionCreatedAt } = this.props.pageContainer.state;
+
     let isNewer;
     let isNewer;
     if (comment.revision === revisionId) {
     if (comment.revision === revisionId) {
       isNewer = 'page-comments-list-current';
       isNewer = 'page-comments-list-current';
@@ -259,12 +256,21 @@ export default class Comment extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const CommentWrapper = (props) => {
+  return createSubscribedElement(Comment, props, [AppContainer, PageContainer]);
+};
+
 Comment.propTypes = {
 Comment.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
   comment: PropTypes.object.isRequired,
   comment: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  growiRenderer: PropTypes.object.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
-  crowi: PropTypes.object.isRequired,
-  revisionId: PropTypes.string,
   replyList: PropTypes.array,
   replyList: PropTypes.array,
-  revisionCreatedAt: PropTypes.number,
 };
 };
+
+export default CommentWrapper;

+ 35 - 55
src/client/js/components/PageComment/CommentEditor.jsx

@@ -1,22 +1,24 @@
-/* eslint-disable react/no-multi-comp */
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import { Subscribe } from 'unstated';
-
 import Button from 'react-bootstrap/es/Button';
 import Button from 'react-bootstrap/es/Button';
 import Tab from 'react-bootstrap/es/Tab';
 import Tab from 'react-bootstrap/es/Tab';
 import Tabs from 'react-bootstrap/es/Tabs';
 import Tabs from 'react-bootstrap/es/Tabs';
 import * as toastr from 'toastr';
 import * as toastr from 'toastr';
-import UserPicture from '../User/UserPicture';
 
 
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+import CommentContainer from '../../services/CommentContainer';
+import EditorContainer from '../../services/EditorContainer';
 import GrowiRenderer from '../../util/GrowiRenderer';
 import GrowiRenderer from '../../util/GrowiRenderer';
 
 
+import { createSubscribedElement } from '../UnstatedUtils';
+import UserPicture from '../User/UserPicture';
 import Editor from '../PageEditor/Editor';
 import Editor from '../PageEditor/Editor';
-import CommentContainer from './CommentContainer';
-import CommentPreview from './CommentPreview';
 import SlackNotification from '../SlackNotification';
 import SlackNotification from '../SlackNotification';
 
 
+import CommentPreview from './CommentPreview';
+
 /**
 /**
  *
  *
  * @author Yuki Takei <yuki@weseek.co.jp>
  * @author Yuki Takei <yuki@weseek.co.jp>
@@ -29,7 +31,7 @@ class CommentEditor extends React.Component {
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
-    const config = this.props.crowi.getConfig();
+    const config = this.props.appContainer.getConfig();
     const isUploadable = config.upload.image || config.upload.file;
     const isUploadable = config.upload.image || config.upload.file;
     const isUploadableFile = config.upload.file;
     const isUploadableFile = config.upload.file;
 
 
@@ -43,12 +45,8 @@ class CommentEditor extends React.Component {
       isUploadableFile,
       isUploadableFile,
       errorMessage: undefined,
       errorMessage: undefined,
       hasSlackConfig: config.hasSlackConfig,
       hasSlackConfig: config.hasSlackConfig,
-      isSlackEnabled: false,
-      slackChannels: this.props.slackChannels,
     };
     };
 
 
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'comment' });
-
     this.updateState = this.updateState.bind(this);
     this.updateState = this.updateState.bind(this);
     this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
     this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
 
 
@@ -67,7 +65,7 @@ class CommentEditor extends React.Component {
   }
   }
 
 
   init() {
   init() {
-    const layoutType = this.props.crowi.getConfig().layoutType;
+    const layoutType = this.props.appContainer.getConfig().layoutType;
     this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
     this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
   }
   }
 
 
@@ -87,12 +85,12 @@ class CommentEditor extends React.Component {
     this.renderHtml(this.state.comment);
     this.renderHtml(this.state.comment);
   }
   }
 
 
-  onSlackEnabledFlagChange(value) {
-    this.setState({ isSlackEnabled: value });
+  onSlackEnabledFlagChange(isSlackEnabled) {
+    this.props.commentContainer.setState({ isSlackEnabled });
   }
   }
 
 
-  onSlackChannelsChange(value) {
-    this.setState({ slackChannels: value });
+  onSlackChannelsChange(slackChannels) {
+    this.props.commentContainer.setState({ slackChannels });
   }
   }
 
 
   toggleEditor() {
   toggleEditor() {
@@ -107,12 +105,14 @@ class CommentEditor extends React.Component {
       event.preventDefault();
       event.preventDefault();
     }
     }
 
 
+    const { commentContainer } = this.props;
+
     this.props.commentContainer.postComment(
     this.props.commentContainer.postComment(
       this.state.comment,
       this.state.comment,
       this.state.isMarkdown,
       this.state.isMarkdown,
       this.props.replyTo,
       this.props.replyTo,
-      this.state.isSlackEnabled,
-      this.state.slackChannels,
+      commentContainer.state.isSlackEnabled,
+      commentContainer.state.slackChannels,
     )
     )
       .then((res) => {
       .then((res) => {
         this.setState({
         this.setState({
@@ -121,7 +121,6 @@ class CommentEditor extends React.Component {
           html: '',
           html: '',
           key: 1,
           key: 1,
           errorMessage: undefined,
           errorMessage: undefined,
-          isSlackEnabled: false,
         });
         });
         // reset value
         // reset value
         this.editor.setValue('');
         this.editor.setValue('');
@@ -179,8 +178,8 @@ class CommentEditor extends React.Component {
       markdown,
       markdown,
     };
     };
 
 
-    const growiRenderer = this.growiRenderer;
-    const interceptorManager = this.props.crowi.interceptorManager;
+    const { growiRenderer } = this.props;
+    const interceptorManager = this.props.appContainer.interceptorManager;
     interceptorManager.process('preRenderCommnetPreview', context)
     interceptorManager.process('preRenderCommnetPreview', context)
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {
       .then(() => {
@@ -209,11 +208,11 @@ class CommentEditor extends React.Component {
   }
   }
 
 
   render() {
   render() {
-    const crowi = this.props.crowi;
-    const username = crowi.me;
-    const user = crowi.findUser(username);
+    const { appContainer, commentContainer } = this.props;
+    const username = appContainer.me;
+    const user = appContainer.findUser(username);
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
-    const emojiStrategy = this.props.crowi.getEmojiStrategy();
+    const emojiStrategy = appContainer.getEmojiStrategy();
 
 
     const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
     const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
 
 
@@ -249,9 +248,8 @@ class CommentEditor extends React.Component {
                       ref={(c) => { this.editor = c }}
                       ref={(c) => { this.editor = c }}
                       value={this.state.comment}
                       value={this.state.comment}
                       isGfmMode={this.state.isMarkdown}
                       isGfmMode={this.state.isMarkdown}
-                      editorOptions={this.props.editorOptions}
                       lineNumbers={false}
                       lineNumbers={false}
-                      isMobile={this.props.crowi.isMobile}
+                      isMobile={appContainer.isMobile}
                       isUploadable={this.state.isUploadable && this.state.isLayoutTypeGrowi} // enable only when GROWI layout
                       isUploadable={this.state.isUploadable && this.state.isLayoutTypeGrowi} // enable only when GROWI layout
                       isUploadableFile={this.state.isUploadableFile}
                       isUploadableFile={this.state.isUploadableFile}
                       emojiStrategy={emojiStrategy}
                       emojiStrategy={emojiStrategy}
@@ -295,8 +293,8 @@ class CommentEditor extends React.Component {
                     && (
                     && (
                     <div className="form-inline align-self-center mr-md-2">
                     <div className="form-inline align-self-center mr-md-2">
                       <SlackNotification
                       <SlackNotification
-                        isSlackEnabled={this.state.isSlackEnabled}
-                        slackChannels={this.state.slackChannels}
+                        isSlackEnabled={commentContainer.state.isSlackEnabled}
+                        slackChannels={commentContainer.state.slackChannels}
                         onEnabledFlagChange={this.onSlackEnabledFlagChange}
                         onEnabledFlagChange={this.onSlackEnabledFlagChange}
                         onChannelChange={this.onSlackChannelsChange}
                         onChannelChange={this.onSlackChannelsChange}
                       />
                       />
@@ -332,35 +330,17 @@ class CommentEditor extends React.Component {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-class CommentEditorWrapper extends React.Component {
-
-  render() {
-    return (
-      <Subscribe to={[CommentContainer]}>
-        { commentContainer => (
-          // eslint-disable-next-line arrow-body-style
-          <CommentEditor commentContainer={commentContainer} {...this.props} />
-        )}
-      </Subscribe>
-    );
-  }
-
-}
+const CommentEditorWrapper = (props) => {
+  return createSubscribedElement(CommentEditor, props, [AppContainer, PageContainer, EditorContainer, CommentContainer]);
+};
 
 
 CommentEditor.propTypes = {
 CommentEditor.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiOriginRenderer: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
   commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
-  editorOptions: PropTypes.object,
-  slackChannels: PropTypes.string,
-  replyTo: PropTypes.string,
-  commentButtonClickedHandler: PropTypes.func.isRequired,
-};
-CommentEditorWrapper.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiOriginRenderer: PropTypes.object.isRequired,
-  editorOptions: PropTypes.object,
-  slackChannels: PropTypes.string,
+
+  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   replyTo: PropTypes.string,
   replyTo: PropTypes.string,
   commentButtonClickedHandler: PropTypes.func.isRequired,
   commentButtonClickedHandler: PropTypes.func.isRequired,
 };
 };

+ 22 - 11
src/client/js/components/PageComment/CommentEditorLazyRenderer.jsx

@@ -1,10 +1,13 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import CommentEditor from './CommentEditor';
 
 
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
 import UserPicture from '../User/UserPicture';
 import UserPicture from '../User/UserPicture';
 
 
-export default class CommentEditorLazyRenderer extends React.Component {
+import CommentEditor from './CommentEditor';
+
+class CommentEditorLazyRenderer extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -14,6 +17,8 @@ export default class CommentEditorLazyRenderer extends React.Component {
       isLayoutTypeGrowi: false,
       isLayoutTypeGrowi: false,
     };
     };
 
 
+    this.growiRenderer = this.props.appContainer.getRenderer('comment');
+
     this.showCommentFormBtnClickHandler = this.showCommentFormBtnClickHandler.bind(this);
     this.showCommentFormBtnClickHandler = this.showCommentFormBtnClickHandler.bind(this);
   }
   }
 
 
@@ -22,7 +27,7 @@ export default class CommentEditorLazyRenderer extends React.Component {
   }
   }
 
 
   init() {
   init() {
-    const layoutType = this.props.crowi.getConfig().layoutType;
+    const layoutType = this.props.appContainer.getConfig().layoutType;
     this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
     this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
   }
   }
 
 
@@ -31,9 +36,9 @@ export default class CommentEditorLazyRenderer extends React.Component {
   }
   }
 
 
   render() {
   render() {
-    const crowi = this.props.crowi;
-    const username = crowi.me;
-    const user = crowi.findUser(username);
+    const { appContainer } = this.props;
+    const username = appContainer.me;
+    const user = appContainer.findUser(username);
     const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
     const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
     return (
     return (
       <React.Fragment>
       <React.Fragment>
@@ -68,7 +73,7 @@ export default class CommentEditorLazyRenderer extends React.Component {
         { this.state.isEditorShown
         { this.state.isEditorShown
           && (
           && (
           <CommentEditor
           <CommentEditor
-            {...this.props}
+            growiRenderer={this.growiRenderer}
             replyTo={undefined}
             replyTo={undefined}
             commentButtonClickedHandler={this.showCommentFormBtnClickHandler}
             commentButtonClickedHandler={this.showCommentFormBtnClickHandler}
           >
           >
@@ -81,9 +86,15 @@ export default class CommentEditorLazyRenderer extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const CommentEditorLazyRendererWrapper = (props) => {
+  return createSubscribedElement(CommentEditorLazyRenderer, props, [AppContainer]);
+};
+
 CommentEditorLazyRenderer.propTypes = {
 CommentEditorLazyRenderer.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiOriginRenderer: PropTypes.object.isRequired,
-  editorOptions: PropTypes.object,
-  slackChannels: PropTypes.string,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 };
 };
+
+export default CommentEditorLazyRendererWrapper;

+ 20 - 54
src/client/js/components/PageComments.jsx

@@ -1,19 +1,20 @@
-/* eslint-disable react/no-multi-comp */
-/* eslint-disable react/no-access-state-in-setstate */
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
-import Button from 'react-bootstrap/es/Button';
 
 
-import { Subscribe } from 'unstated';
+import Button from 'react-bootstrap/es/Button';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
-import GrowiRenderer from '../util/GrowiRenderer';
 
 
-import CommentContainer from './PageComment/CommentContainer';
+import AppContainer from '../services/AppContainer';
+import CommentContainer from '../services/CommentContainer';
+
+import { createSubscribedElement } from './UnstatedUtils';
 import CommentEditor from './PageComment/CommentEditor';
 import CommentEditor from './PageComment/CommentEditor';
 
 
 import Comment from './PageComment/Comment';
 import Comment from './PageComment/Comment';
 import DeleteCommentModal from './PageComment/DeleteCommentModal';
 import DeleteCommentModal from './PageComment/DeleteCommentModal';
+import PageContainer from '../services/PageContainer';
+
 
 
 /**
 /**
  * Load data of comments and render the list of <Comment />
  * Load data of comments and render the list of <Comment />
@@ -40,7 +41,7 @@ class PageComments extends React.Component {
       showEditorIds: new Set(),
       showEditorIds: new Set(),
     };
     };
 
 
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'comment' });
+    this.growiRenderer = this.props.appContainer.getRenderer('comment');
 
 
     this.init = this.init.bind(this);
     this.init = this.init.bind(this);
     this.confirmToDeleteComment = this.confirmToDeleteComment.bind(this);
     this.confirmToDeleteComment = this.confirmToDeleteComment.bind(this);
@@ -56,11 +57,11 @@ class PageComments extends React.Component {
   }
   }
 
 
   init() {
   init() {
-    if (!this.props.pageId) {
+    if (!this.props.pageContainer.state.pageId) {
       return;
       return;
     }
     }
 
 
-    const layoutType = this.props.crowi.getConfig().layoutType;
+    const layoutType = this.props.appContainer.getConfig().layoutType;
     this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
     this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
 
 
     this.props.commentContainer.retrieveComments();
     this.props.commentContainer.retrieveComments();
@@ -138,8 +139,7 @@ class PageComments extends React.Component {
 
 
       const commentId = comment._id;
       const commentId = comment._id;
       const showEditor = this.state.showEditorIds.has(commentId);
       const showEditor = this.state.showEditorIds.has(commentId);
-      const crowi = this.props.crowi;
-      const username = crowi.me;
+      const username = this.props.appContainer.me;
 
 
       const replyList = this.addRepliesToComments(comment, replies);
       const replyList = this.addRepliesToComments(comment, replies);
 
 
@@ -148,11 +148,8 @@ class PageComments extends React.Component {
           <Comment
           <Comment
             comment={comment}
             comment={comment}
             deleteBtnClicked={this.confirmToDeleteComment}
             deleteBtnClicked={this.confirmToDeleteComment}
-            crowiRenderer={this.growiRenderer}
-            crowi={this.props.crowi}
+            growiRenderer={this.growiRenderer}
             replyList={replyList}
             replyList={replyList}
-            revisionCreatedAt={this.props.revisionCreatedAt}
-            revisionId={this.props.revisionId}
           />
           />
           <div className="container-fluid">
           <div className="container-fluid">
             <div className="row">
             <div className="row">
@@ -176,10 +173,7 @@ class PageComments extends React.Component {
                 )}
                 )}
                 { showEditor && (
                 { showEditor && (
                   <CommentEditor
                   <CommentEditor
-                    crowi={this.props.crowi}
-                    crowiOriginRenderer={this.props.crowiOriginRenderer}
-                    editorOptions={this.props.editorOptions}
-                    slackChannels={this.props.slackChannels}
+                    growiRenderer={this.growiRenderer}
                     replyTo={commentId}
                     replyTo={commentId}
                     commentButtonClickedHandler={this.commentButtonClickedHandler}
                     commentButtonClickedHandler={this.commentButtonClickedHandler}
                   />
                   />
@@ -247,42 +241,14 @@ class PageComments extends React.Component {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-class PageCommentsWrapper extends React.Component {
-
-  render() {
-    return (
-      <Subscribe to={[CommentContainer]}>
-        { commentContainer => (
-          // eslint-disable-next-line arrow-body-style
-          <PageComments commentContainer={commentContainer} {...this.props} />
-        )}
-      </Subscribe>
-    );
-  }
-
-}
-
-PageCommentsWrapper.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiOriginRenderer: PropTypes.object.isRequired,
-  pageId: PropTypes.string.isRequired,
-  revisionId: PropTypes.string.isRequired,
-  revisionCreatedAt: PropTypes.number,
-  pagePath: PropTypes.string,
-  editorOptions: PropTypes.object,
-  slackChannels: PropTypes.string,
+const PageCommentsWrapper = (props) => {
+  return createSubscribedElement(PageComments, props, [AppContainer, PageContainer, CommentContainer]);
 };
 };
+
 PageComments.propTypes = {
 PageComments.propTypes = {
-  commentContainer: PropTypes.object.isRequired,
-
-  crowi: PropTypes.object.isRequired,
-  crowiOriginRenderer: PropTypes.object.isRequired,
-  pageId: PropTypes.string.isRequired,
-  revisionId: PropTypes.string.isRequired,
-  revisionCreatedAt: PropTypes.number,
-  pagePath: PropTypes.string,
-  editorOptions: PropTypes.object,
-  slackChannels: PropTypes.string,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  commentContainer: PropTypes.instanceOf(CommentContainer).isRequired,
 };
 };
 
 
-export default withTranslation(null, { withRef: true })(PageCommentsWrapper);
+export default withTranslation()(PageCommentsWrapper);

+ 48 - 58
src/client/js/components/PageEditor.js → src/client/js/components/PageEditor.jsx

@@ -4,37 +4,33 @@ import PropTypes from 'prop-types';
 import { throttle, debounce } from 'throttle-debounce';
 import { throttle, debounce } from 'throttle-debounce';
 
 
 import * as toastr from 'toastr';
 import * as toastr from 'toastr';
-import GrowiRenderer from '../util/GrowiRenderer';
 
 
-import { EditorOptions, PreviewOptions } from './PageEditor/OptionsSelector';
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+
+import { createSubscribedElement } from './UnstatedUtils';
 import Editor from './PageEditor/Editor';
 import Editor from './PageEditor/Editor';
 import Preview from './PageEditor/Preview';
 import Preview from './PageEditor/Preview';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
 
 
 
 
-export default class PageEditor extends React.Component {
+class PageEditor extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
-    const config = this.props.crowi.getConfig();
+    const config = this.props.appContainer.getConfig();
     const isUploadable = config.upload.image || config.upload.file;
     const isUploadable = config.upload.image || config.upload.file;
     const isUploadableFile = config.upload.file;
     const isUploadableFile = config.upload.file;
     const isMathJaxEnabled = !!config.env.MATHJAX;
     const isMathJaxEnabled = !!config.env.MATHJAX;
 
 
     this.state = {
     this.state = {
-      pageId: this.props.pageId,
-      revisionId: this.props.revisionId,
-      markdown: this.props.markdown,
+      markdown: this.props.pageContainer.state.markdown,
       isUploadable,
       isUploadable,
       isUploadableFile,
       isUploadableFile,
       isMathJaxEnabled,
       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.setCaretLine = this.setCaretLine.bind(this);
     this.focusToEditor = this.focusToEditor.bind(this);
     this.focusToEditor = this.focusToEditor.bind(this);
     this.onMarkdownChanged = this.onMarkdownChanged.bind(this);
     this.onMarkdownChanged = this.onMarkdownChanged.bind(this);
@@ -48,6 +44,9 @@ export default class PageEditor extends React.Component {
     this.apiErrorHandler = this.apiErrorHandler.bind(this);
     this.apiErrorHandler = this.apiErrorHandler.bind(this);
     this.showUnsavedWarning = this.showUnsavedWarning.bind(this);
     this.showUnsavedWarning = this.showUnsavedWarning.bind(this);
 
 
+    // get renderer
+    this.growiRenderer = this.props.appContainer.getRenderer('editor');
+
     // for scrolling
     // for scrolling
     this.lastScrolledDateWithCursor = null;
     this.lastScrolledDateWithCursor = null;
     this.isOriginOfScrollSyncEditor = false;
     this.isOriginOfScrollSyncEditor = false;
@@ -59,21 +58,24 @@ export default class PageEditor extends React.Component {
     this.scrollEditorByPreviewScrollWithThrottle = throttle(20, this.scrollEditorByPreviewScroll);
     this.scrollEditorByPreviewScrollWithThrottle = throttle(20, this.scrollEditorByPreviewScroll);
     this.renderPreviewWithDebounce = debounce(50, throttle(100, this.renderPreview));
     this.renderPreviewWithDebounce = debounce(50, throttle(100, this.renderPreview));
     this.saveDraftWithDebounce = debounce(800, this.saveDraft);
     this.saveDraftWithDebounce = debounce(800, this.saveDraft);
+
   }
   }
 
 
   componentWillMount() {
   componentWillMount() {
+    this.props.appContainer.registerComponentInstance(this);
+
     // initial rendering
     // initial rendering
     this.renderPreview(this.state.markdown);
     this.renderPreview(this.state.markdown);
 
 
-    this.props.crowi.window.addEventListener('beforeunload', this.showUnsavedWarning);
+    window.addEventListener('beforeunload', this.showUnsavedWarning);
   }
   }
 
 
   componentWillUnmount() {
   componentWillUnmount() {
-    this.props.crowi.window.removeEventListener('beforeunload', this.showUnsavedWarning);
+    window.removeEventListener('beforeunload', this.showUnsavedWarning);
   }
   }
 
 
   showUnsavedWarning(e) {
   showUnsavedWarning(e) {
-    if (!this.props.crowi.getIsDocSaved()) {
+    if (!this.props.appContainer.getIsDocSaved()) {
       // display browser default message
       // display browser default message
       e.returnValue = '';
       e.returnValue = '';
       return '';
       return '';
@@ -84,11 +86,8 @@ export default class PageEditor extends React.Component {
     return this.state.markdown;
     return this.state.markdown;
   }
   }
 
 
-  setMarkdown(markdown, updateEditorValue = true) {
-    this.setState({ markdown });
-    if (updateEditorValue) {
-      this.editor.setValue(markdown);
-    }
+  updateEditorValue(markdown) {
+    this.editor.setValue(markdown);
   }
   }
 
 
   focusToEditor() {
   focusToEditor() {
@@ -104,22 +103,6 @@ export default class PageEditor extends React.Component {
     scrollSyncHelper.scrollPreview(this.previewElement, line);
     scrollSyncHelper.scrollPreview(this.previewElement, line);
   }
   }
 
 
-  /**
-   * set options (used from the outside)
-   * @param {object} editorOptions
-   */
-  setEditorOptions(editorOptions) {
-    this.setState({ editorOptions });
-  }
-
-  /**
-   * set options (used from the outside)
-   * @param {object} previewOptions
-   */
-  setPreviewOptions(previewOptions) {
-    this.setState({ previewOptions });
-  }
-
   /**
   /**
    * the change event handler for `markdown` state
    * the change event handler for `markdown` state
    * @param {string} value
    * @param {string} value
@@ -127,12 +110,12 @@ export default class PageEditor extends React.Component {
   onMarkdownChanged(value) {
   onMarkdownChanged(value) {
     this.renderPreviewWithDebounce(value);
     this.renderPreviewWithDebounce(value);
     this.saveDraftWithDebounce();
     this.saveDraftWithDebounce();
-    this.props.crowi.setIsDocSaved(false);
+    this.props.appContainer.setIsDocSaved(false);
   }
   }
 
 
   onSave() {
   onSave() {
     this.props.onSaveWithShortcut(this.state.markdown);
     this.props.onSaveWithShortcut(this.state.markdown);
-    this.props.crowi.setIsDocSaved(true);
+    this.props.appContainer.setIsDocSaved(true);
   }
   }
 
 
   /**
   /**
@@ -141,18 +124,22 @@ export default class PageEditor extends React.Component {
    */
    */
   async onUpload(file) {
   async onUpload(file) {
     try {
     try {
-      let res = await this.props.crowi.apiGet('/attachments.limit', { _csrf: this.props.crowi.csrfToken, fileSize: file.size });
+      let res = await this.props.appContainer.apiGet('/attachments.limit', {
+        _csrf: this.props.appContainer.csrfToken,
+        fileSize: file.size,
+      });
+
       if (!res.isUploadable) {
       if (!res.isUploadable) {
         throw new Error(res.errorMessage);
         throw new Error(res.errorMessage);
       }
       }
 
 
       const formData = new FormData();
       const formData = new FormData();
-      formData.append('_csrf', this.props.crowi.csrfToken);
+      formData.append('_csrf', this.props.appContainer.csrfToken);
       formData.append('file', file);
       formData.append('file', file);
-      formData.append('path', this.props.pagePath);
+      formData.append('path', this.props.pageContainer.state.path);
       formData.append('page_id', this.state.pageId || 0);
       formData.append('page_id', this.state.pageId || 0);
 
 
-      res = await this.props.crowi.apiPost('/attachments.add', formData);
+      res = await this.props.appContainer.apiPost('/attachments.add', formData);
       const attachment = res.attachment;
       const attachment = res.attachment;
       const fileName = attachment.originalName;
       const fileName = attachment.originalName;
 
 
@@ -275,14 +262,15 @@ export default class PageEditor extends React.Component {
   }
   }
 
 
   saveDraft() {
   saveDraft() {
+    const { pageContainer } = this.props;
     // only when the first time to edit
     // only when the first time to edit
-    if (!this.state.revisionId) {
-      this.props.crowi.saveDraft(this.props.pagePath, this.state.markdown);
+    if (!pageContainer.state.revisionId) {
+      pageContainer.saveDraft(pageContainer.state.path, this.state.markdown);
     }
     }
   }
   }
 
 
   clearDraft() {
   clearDraft() {
-    this.props.crowi.clearDraft(this.props.pagePath);
+    this.props.pageContainer.clearDraft(this.props.pageContainer.state.path);
   }
   }
 
 
   renderPreview(value) {
   renderPreview(value) {
@@ -295,7 +283,7 @@ export default class PageEditor extends React.Component {
     };
     };
 
 
     const growiRenderer = this.growiRenderer;
     const growiRenderer = this.growiRenderer;
-    const interceptorManager = this.props.crowi.interceptorManager;
+    const interceptorManager = this.props.appContainer.interceptorManager;
     interceptorManager.process('preRenderPreview', context)
     interceptorManager.process('preRenderPreview', context)
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => { return interceptorManager.process('prePreProcess', context) })
       .then(() => {
       .then(() => {
@@ -332,9 +320,9 @@ export default class PageEditor extends React.Component {
   }
   }
 
 
   render() {
   render() {
-    const config = this.props.crowi.getConfig();
+    const config = this.props.appContainer.getConfig();
     const noCdn = !!config.env.NO_CDN;
     const noCdn = !!config.env.NO_CDN;
-    const emojiStrategy = this.props.crowi.getEmojiStrategy();
+    const emojiStrategy = this.props.appContainer.getEmojiStrategy();
 
 
     return (
     return (
       <div className="row">
       <div className="row">
@@ -342,9 +330,8 @@ export default class PageEditor extends React.Component {
           <Editor
           <Editor
             ref={(c) => { this.editor = c }}
             ref={(c) => { this.editor = c }}
             value={this.state.markdown}
             value={this.state.markdown}
-            editorOptions={this.state.editorOptions}
             noCdn={noCdn}
             noCdn={noCdn}
-            isMobile={this.props.crowi.isMobile}
+            isMobile={this.props.appContainer.isMobile}
             isUploadable={this.state.isUploadable}
             isUploadable={this.state.isUploadable}
             isUploadableFile={this.state.isUploadableFile}
             isUploadableFile={this.state.isUploadableFile}
             emojiStrategy={emojiStrategy}
             emojiStrategy={emojiStrategy}
@@ -362,7 +349,6 @@ export default class PageEditor extends React.Component {
             inputRef={(el) => { return this.previewElement = el }}
             inputRef={(el) => { return this.previewElement = el }}
             isMathJaxEnabled={this.state.isMathJaxEnabled}
             isMathJaxEnabled={this.state.isMathJaxEnabled}
             renderMathJaxOnInit={false}
             renderMathJaxOnInit={false}
-            previewOptions={this.state.previewOptions}
             onScroll={this.onPreviewScroll}
             onScroll={this.onPreviewScroll}
           />
           />
         </div>
         </div>
@@ -372,14 +358,18 @@ export default class PageEditor extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const PageEditorWrapper = (props) => {
+  return createSubscribedElement(PageEditor, props, [AppContainer, PageContainer]);
+};
+
 PageEditor.propTypes = {
 PageEditor.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
   onSaveWithShortcut: PropTypes.func.isRequired,
   onSaveWithShortcut: PropTypes.func.isRequired,
-  markdown: PropTypes.string.isRequired,
-  pageId: PropTypes.string,
-  revisionId: PropTypes.string,
-  pagePath: PropTypes.string,
-  editorOptions: PropTypes.instanceOf(EditorOptions),
-  previewOptions: PropTypes.instanceOf(PreviewOptions),
 };
 };
+
+export default PageEditorWrapper;

+ 0 - 1
src/client/js/components/PageEditor/AbstractEditor.js → src/client/js/components/PageEditor/AbstractEditor.jsx

@@ -124,7 +124,6 @@ export default class AbstractEditor extends React.Component {
 AbstractEditor.propTypes = {
 AbstractEditor.propTypes = {
   value: PropTypes.string,
   value: PropTypes.string,
   isGfmMode: PropTypes.bool,
   isGfmMode: PropTypes.bool,
-  editorOptions: PropTypes.object,
   onChange: PropTypes.func,
   onChange: PropTypes.func,
   onScroll: PropTypes.func,
   onScroll: PropTypes.func,
   onScrollCursorIntoView: PropTypes.func,
   onScrollCursorIntoView: PropTypes.func,

+ 0 - 0
src/client/js/components/PageEditor/Cheatsheet.js → src/client/js/components/PageEditor/Cheatsheet.jsx


+ 56 - 35
src/client/js/components/PageEditor/CodeMirrorEditor.js → src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -119,6 +119,14 @@ export default class CodeMirrorEditor extends AbstractEditor {
   componentDidMount() {
   componentDidMount() {
     // ensure to be able to resolve 'this' to use 'codemirror.commands.save'
     // ensure to be able to resolve 'this' to use 'codemirror.commands.save'
     this.getCodeMirror().codeMirrorEditor = this;
     this.getCodeMirror().codeMirrorEditor = this;
+
+    // load theme
+    const theme = this.props.editorOptions.theme;
+    this.loadTheme(theme);
+
+    // set keymap
+    const keymapMode = this.props.editorOptions.keymapMode;
+    this.setKeymapMode(keymapMode);
   }
   }
 
 
   componentWillReceiveProps(nextProps) {
   componentWillReceiveProps(nextProps) {
@@ -375,7 +383,24 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
 
     this.loadKeymapMode(keymapMode)
     this.loadKeymapMode(keymapMode)
       .then(() => {
       .then(() => {
-        this.getCodeMirror().setOption('keyMap', keymapMode);
+        let errorCount = 0;
+        const timer = setInterval(() => {
+          if (errorCount > 10) { // cancel over 3000ms
+            this.logger.error(`Timeout to load keyMap '${keymapMode}'`);
+            clearInterval(timer);
+          }
+
+          try {
+            this.getCodeMirror().setOption('keyMap', keymapMode);
+            clearInterval(timer);
+          }
+          catch (e) {
+            this.logger.info(`keyMap '${keymapMode}' has not been initialized. retry..`);
+
+            // continue if error occured
+            errorCount++;
+          }
+        }, 300);
       });
       });
   }
   }
 
 
@@ -717,12 +742,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
 
   render() {
   render() {
     const mode = this.state.isGfmMode ? 'gfm' : undefined;
     const mode = this.state.isGfmMode ? 'gfm' : undefined;
-    const defaultEditorOptions = {
-      theme: 'elegant',
-      lineNumbers: true,
-    };
     const additionalClasses = Array.from(this.state.additionalClassSet).join(' ');
     const additionalClasses = Array.from(this.state.additionalClassSet).join(' ');
-    const editorOptions = Object.assign(defaultEditorOptions, this.props.editorOptions || {});
 
 
     const placeholder = this.state.isGfmMode ? 'Input with Markdown..' : 'Input with Plane Text..';
     const placeholder = this.state.isGfmMode ? 'Input with Markdown..' : 'Input with Plane Text..';
 
 
@@ -740,35 +760,35 @@ export default class CodeMirrorEditor extends AbstractEditor {
         }}
         }}
           value={this.state.value}
           value={this.state.value}
           options={{
           options={{
-          mode,
-          theme: editorOptions.theme,
-          styleActiveLine: editorOptions.styleActiveLine,
-          lineNumbers: this.props.lineNumbers,
-          tabSize: 4,
-          indentUnit: 4,
-          lineWrapping: true,
-          autoRefresh: { force: true }, // force option is enabled by autorefresh.ext.js -- Yuki Takei
-          autoCloseTags: true,
-          placeholder,
-          matchBrackets: true,
-          matchTags: { bothTags: true },
-          // folding
-          foldGutter: this.props.lineNumbers,
-          gutters: this.props.lineNumbers ? ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'] : [],
-          // match-highlighter, matchesonscrollbar, annotatescrollbar options
-          highlightSelectionMatches: { annotateScrollbar: true },
-          // markdown mode options
-          highlightFormatting: true,
-          // continuelist, indentlist
-          extraKeys: {
-            Enter: this.handleEnterKey,
-            'Ctrl-Enter': this.handleCtrlEnterKey,
-            'Cmd-Enter': this.handleCtrlEnterKey,
-            Tab: 'indentMore',
-            'Shift-Tab': 'indentLess',
-            'Ctrl-Q': (cm) => { cm.foldCode(cm.getCursor()) },
-          },
-        }}
+            mode,
+            theme: this.props.editorOptions.theme,
+            styleActiveLine: this.props.editorOptions.styleActiveLine,
+            lineNumbers: this.props.lineNumbers,
+            tabSize: 4,
+            indentUnit: 4,
+            lineWrapping: true,
+            autoRefresh: { force: true }, // force option is enabled by autorefresh.ext.js -- Yuki Takei
+            autoCloseTags: true,
+            placeholder,
+            matchBrackets: true,
+            matchTags: { bothTags: true },
+            // folding
+            foldGutter: this.props.lineNumbers,
+            gutters: this.props.lineNumbers ? ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'] : [],
+            // match-highlighter, matchesonscrollbar, annotatescrollbar options
+            highlightSelectionMatches: { annotateScrollbar: true },
+            // markdown mode options
+            highlightFormatting: true,
+            // continuelist, indentlist
+            extraKeys: {
+              Enter: this.handleEnterKey,
+              'Ctrl-Enter': this.handleCtrlEnterKey,
+              'Cmd-Enter': this.handleCtrlEnterKey,
+              Tab: 'indentMore',
+              'Shift-Tab': 'indentLess',
+              'Ctrl-Q': (cm) => { cm.foldCode(cm.getCursor()) },
+            },
+          }}
           onCursor={this.cursorHandler}
           onCursor={this.cursorHandler}
           onScroll={(editor, data) => {
           onScroll={(editor, data) => {
           if (this.props.onScroll != null) {
           if (this.props.onScroll != null) {
@@ -804,6 +824,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 }
 }
 
 
 CodeMirrorEditor.propTypes = Object.assign({
 CodeMirrorEditor.propTypes = Object.assign({
+  editorOptions: PropTypes.object.isRequired,
   emojiStrategy: PropTypes.object,
   emojiStrategy: PropTypes.object,
   lineNumbers: PropTypes.bool,
   lineNumbers: PropTypes.bool,
 }, AbstractEditor.propTypes);
 }, AbstractEditor.propTypes);

+ 17 - 10
src/client/js/components/PageEditor/Editor.jsx

@@ -1,6 +1,8 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
+import { Subscribe } from 'unstated';
+
 import Dropzone from 'react-dropzone';
 import Dropzone from 'react-dropzone';
 import AbstractEditor from './AbstractEditor';
 import AbstractEditor from './AbstractEditor';
 import CodeMirrorEditor from './CodeMirrorEditor';
 import CodeMirrorEditor from './CodeMirrorEditor';
@@ -8,6 +10,7 @@ import TextAreaEditor from './TextAreaEditor';
 
 
 
 
 import pasteHelper from './PasteHelper';
 import pasteHelper from './PasteHelper';
+import EditorContainer from '../../services/EditorContainer';
 
 
 export default class Editor extends AbstractEditor {
 export default class Editor extends AbstractEditor {
 
 
@@ -271,14 +274,19 @@ export default class Editor extends AbstractEditor {
 
 
                 {/* for PC */}
                 {/* for PC */}
                 { !isMobile && (
                 { !isMobile && (
-                  <CodeMirrorEditor
-                    ref={(c) => { this.cmEditor = c }}
-                    onPasteFiles={this.pasteFilesHandler}
-                    onDragEnter={this.dragEnterHandler}
-                    {...this.props}
-                  />
-                  )
-                }
+                  <Subscribe to={[EditorContainer]}>
+                    { editorContainer => (
+                      // eslint-disable-next-line arrow-body-style
+                      <CodeMirrorEditor
+                        ref={(c) => { this.cmEditor = c }}
+                        editorOptions={editorContainer.state.editorOptions}
+                        onPasteFiles={this.pasteFilesHandler}
+                        onDragEnter={this.dragEnterHandler}
+                        {...this.props}
+                      />
+                    )}
+                  </Subscribe>
+                )}
 
 
                 {/* for mobile */}
                 {/* for mobile */}
                 { isMobile && (
                 { isMobile && (
@@ -288,8 +296,7 @@ export default class Editor extends AbstractEditor {
                     onDragEnter={this.dragEnterHandler}
                     onDragEnter={this.dragEnterHandler}
                     {...this.props}
                     {...this.props}
                   />
                   />
-                  )
-                }
+                )}
 
 
                 <input {...getInputProps()} />
                 <input {...getInputProps()} />
               </div>
               </div>

+ 0 - 0
src/client/js/components/PageEditor/MarkdownTableUtil.js → src/client/js/components/PageEditor/MarkdownTableUtil.jsx


+ 56 - 54
src/client/js/components/PageEditor/OptionsSelector.js → src/client/js/components/PageEditor/OptionsSelector.jsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
 import FormGroup from 'react-bootstrap/es/FormGroup';
 import FormGroup from 'react-bootstrap/es/FormGroup';
@@ -9,27 +10,19 @@ import ControlLabel from 'react-bootstrap/es/ControlLabel';
 import Dropdown from 'react-bootstrap/es/Dropdown';
 import Dropdown from 'react-bootstrap/es/Dropdown';
 import MenuItem from 'react-bootstrap/es/MenuItem';
 import MenuItem from 'react-bootstrap/es/MenuItem';
 
 
-export class EditorOptions {
-
-  constructor(props) {
-    this.theme = 'elegant';
-    this.keymapMode = 'default';
-    this.styleActiveLine = false;
-
-    Object.assign(this, props);
-  }
-
-}
+import { createSubscribedElement } from '../UnstatedUtils';
+import EditorContainer from '../../services/EditorContainer';
 
 
-export class PreviewOptions {
 
 
-  constructor(props) {
-    this.renderMathJaxInRealtime = false;
-
-    Object.assign(this, props);
-  }
+export const defaultEditorOptions = {
+  theme: 'elegant',
+  keymapMode: 'default',
+  styleActiveLine: false,
+};
 
 
-}
+export const defaultPreviewOptions = {
+  renderMathJaxInRealtime: false,
+};
 
 
 class OptionsSelector extends React.Component {
 class OptionsSelector extends React.Component {
 
 
@@ -40,8 +33,6 @@ class OptionsSelector extends React.Component {
     const isMathJaxEnabled = !!config.env.MATHJAX;
     const isMathJaxEnabled = !!config.env.MATHJAX;
 
 
     this.state = {
     this.state = {
-      editorOptions: this.props.editorOptions || new EditorOptions(),
-      previewOptions: this.props.previewOptions || new PreviewOptions(),
       isCddMenuOpened: false,
       isCddMenuOpened: false,
       isMathJaxEnabled,
       isMathJaxEnabled,
     };
     };
@@ -68,50 +59,60 @@ class OptionsSelector extends React.Component {
   }
   }
 
 
   init() {
   init() {
-    this.themeSelectorInputEl.value = this.state.editorOptions.theme;
-    this.keymapModeSelectorInputEl.value = this.state.editorOptions.keymapMode;
+    const { editorContainer } = this.props;
+
+    this.themeSelectorInputEl.value = editorContainer.state.editorOptions.theme;
+    this.keymapModeSelectorInputEl.value = editorContainer.state.editorOptions.keymapMode;
   }
   }
 
 
   onChangeTheme() {
   onChangeTheme() {
+    const { editorContainer } = this.props;
+
     const newValue = this.themeSelectorInputEl.value;
     const newValue = this.themeSelectorInputEl.value;
-    const newOpts = Object.assign(this.state.editorOptions, { theme: newValue });
-    this.setState({ editorOptions: newOpts });
+    const newOpts = Object.assign(editorContainer.state.editorOptions, { theme: newValue });
+    editorContainer.setState({ editorOptions: newOpts });
 
 
-    // dispatch event
-    this.dispatchOnChange();
+    // save to localStorage
+    editorContainer.saveOptsToLocalStorage();
   }
   }
 
 
   onChangeKeymapMode() {
   onChangeKeymapMode() {
+    const { editorContainer } = this.props;
+
     const newValue = this.keymapModeSelectorInputEl.value;
     const newValue = this.keymapModeSelectorInputEl.value;
-    const newOpts = Object.assign(this.state.editorOptions, { keymapMode: newValue });
-    this.setState({ editorOptions: newOpts });
+    const newOpts = Object.assign(editorContainer.state.editorOptions, { keymapMode: newValue });
+    editorContainer.setState({ editorOptions: newOpts });
 
 
-    // dispatch event
-    this.dispatchOnChange();
+    // save to localStorage
+    editorContainer.saveOptsToLocalStorage();
   }
   }
 
 
   onClickStyleActiveLine(event) {
   onClickStyleActiveLine(event) {
+    const { editorContainer } = this.props;
+
     // keep dropdown opened
     // keep dropdown opened
     this._cddForceOpen = true;
     this._cddForceOpen = true;
 
 
-    const newValue = !this.state.editorOptions.styleActiveLine;
-    const newOpts = Object.assign(this.state.editorOptions, { styleActiveLine: newValue });
-    this.setState({ editorOptions: newOpts });
+    const newValue = !editorContainer.state.editorOptions.styleActiveLine;
+    const newOpts = Object.assign(editorContainer.state.editorOptions, { styleActiveLine: newValue });
+    editorContainer.setState({ editorOptions: newOpts });
 
 
-    // dispatch event
-    this.dispatchOnChange();
+    // save to localStorage
+    editorContainer.saveOptsToLocalStorage();
   }
   }
 
 
   onClickRenderMathJaxInRealtime(event) {
   onClickRenderMathJaxInRealtime(event) {
+    const { editorContainer } = this.props;
+
     // keep dropdown opened
     // keep dropdown opened
     this._cddForceOpen = true;
     this._cddForceOpen = true;
 
 
-    const newValue = !this.state.previewOptions.renderMathJaxInRealtime;
-    const newOpts = Object.assign(this.state.previewOptions, { renderMathJaxInRealtime: newValue });
-    this.setState({ previewOptions: newOpts });
+    const newValue = !editorContainer.state.previewOptions.renderMathJaxInRealtime;
+    const newOpts = Object.assign(editorContainer.state.previewOptions, { renderMathJaxInRealtime: newValue });
+    editorContainer.setState({ previewOptions: newOpts });
 
 
-    // dispatch event
-    this.dispatchOnChange();
+    // save to localStorage
+    editorContainer.saveOptsToLocalStorage();
   }
   }
 
 
   /*
   /*
@@ -127,13 +128,6 @@ class OptionsSelector extends React.Component {
     }
     }
   }
   }
 
 
-  /**
-   * dispatch onChange event
-   */
-  dispatchOnChange() {
-    this.props.onChange(this.state.editorOptions, this.state.previewOptions);
-  }
-
   renderThemeSelector() {
   renderThemeSelector() {
     const optionElems = this.availableThemes.map((theme) => {
     const optionElems = this.availableThemes.map((theme) => {
       return <option key={theme} value={theme}>{theme}</option>;
       return <option key={theme} value={theme}>{theme}</option>;
@@ -225,8 +219,8 @@ class OptionsSelector extends React.Component {
   }
   }
 
 
   renderActiveLineMenuItem() {
   renderActiveLineMenuItem() {
-    const { t } = this.props;
-    const isActive = this.state.editorOptions.styleActiveLine;
+    const { t, editorContainer } = this.props;
+    const isActive = editorContainer.state.editorOptions.styleActiveLine;
 
 
     const iconClasses = ['text-info'];
     const iconClasses = ['text-info'];
     if (isActive) {
     if (isActive) {
@@ -248,8 +242,10 @@ class OptionsSelector extends React.Component {
       return;
       return;
     }
     }
 
 
+    const { editorContainer } = this.props;
+
     const isEnabled = this.state.isMathJaxEnabled;
     const isEnabled = this.state.isMathJaxEnabled;
-    const isActive = isEnabled && this.state.previewOptions.renderMathJaxInRealtime;
+    const isActive = isEnabled && editorContainer.state.previewOptions.renderMathJaxInRealtime;
 
 
     const iconClasses = ['text-info'];
     const iconClasses = ['text-info'];
     if (isActive) {
     if (isActive) {
@@ -278,13 +274,19 @@ class OptionsSelector extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const OptionsSelectorWrapper = (props) => {
+  return createSubscribedElement(OptionsSelector, props, [EditorContainer]);
+};
 
 
 OptionsSelector.propTypes = {
 OptionsSelector.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
+
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
   crowi: PropTypes.object.isRequired,
   crowi: PropTypes.object.isRequired,
-  editorOptions: PropTypes.instanceOf(EditorOptions).isRequired,
-  previewOptions: PropTypes.instanceOf(PreviewOptions).isRequired,
-  onChange: PropTypes.func.isRequired,
 };
 };
 
 
-export default withTranslation()(OptionsSelector);
+export default withTranslation()(OptionsSelectorWrapper);

+ 0 - 47
src/client/js/components/PageEditor/Preview.js

@@ -1,47 +0,0 @@
-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 {
-
-  render() {
-    const renderMathJaxInRealtime = this.props.previewOptions.renderMathJaxInRealtime;
-
-    return (
-      <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>
-    );
-  }
-
-}
-
-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,
-};

+ 50 - 0
src/client/js/components/PageEditor/Preview.jsx

@@ -0,0 +1,50 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { Subscribe } from 'unstated';
+
+import RevisionBody from '../Page/RevisionBody';
+
+import EditorContainer from '../../services/EditorContainer';
+
+/**
+ * Wrapper component for Page/RevisionBody
+ */
+export default class Preview extends React.PureComponent {
+
+  render() {
+    return (
+      <Subscribe to={[EditorContainer]}>
+        { editorContainer => (
+          // eslint-disable-next-line arrow-body-style
+          <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={editorContainer.state.previewOptions.renderMathJaxInRealtime}
+            />
+          </div>
+        )}
+      </Subscribe>
+    );
+  }
+
+}
+
+Preview.propTypes = {
+  html: PropTypes.string,
+  inputRef: PropTypes.func.isRequired, // for getting div element
+  isMathJaxEnabled: PropTypes.bool,
+  renderMathJaxOnInit: PropTypes.bool,
+  onScroll: PropTypes.func,
+};

+ 0 - 0
src/client/js/components/PageEditor/SimpleCheatsheet.js → src/client/js/components/PageEditor/SimpleCheatsheet.jsx


+ 0 - 0
src/client/js/components/PageEditor/TextAreaEditor.js → src/client/js/components/PageEditor/TextAreaEditor.jsx


+ 39 - 66
src/client/js/components/PageEditorByHackmd.jsx

@@ -6,22 +6,21 @@ import MenuItem from 'react-bootstrap/es/MenuItem';
 
 
 import * as toastr from 'toastr';
 import * as toastr from 'toastr';
 
 
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+
+import { createSubscribedElement } from './UnstatedUtils';
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
 
 
-export default class PageEditorByHackmd extends React.PureComponent {
+class PageEditorByHackmd extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
     this.state = {
     this.state = {
-      markdown: this.props.markdown,
+      markdown: this.props.pageContainer.state.markdown,
       isInitialized: false,
       isInitialized: false,
       isInitializing: false,
       isInitializing: false,
-      initialRevisionId: this.props.revisionId,
-      revisionId: this.props.revisionId,
-      revisionIdHackmdSynced: this.props.revisionIdHackmdSynced,
-      pageIdOnHackmd: this.props.pageIdOnHackmd,
-      hasDraftOnHackmd: this.props.hasDraftOnHackmd,
     };
     };
 
 
     this.getHackmdUri = this.getHackmdUri.bind(this);
     this.getHackmdUri = this.getHackmdUri.bind(this);
@@ -33,6 +32,7 @@ export default class PageEditorByHackmd extends React.PureComponent {
   }
   }
 
 
   componentWillMount() {
   componentWillMount() {
+    this.props.appContainer.registerComponentInstance(this);
   }
   }
 
 
   /**
   /**
@@ -51,13 +51,6 @@ export default class PageEditorByHackmd extends React.PureComponent {
       });
       });
   }
   }
 
 
-  setMarkdown(markdown, updateEditorValue = true) {
-    this.setState({ markdown });
-    if (this.state.isInitialized && updateEditorValue) {
-      this.hackmdEditor.setValue(markdown);
-    }
-  }
-
   /**
   /**
    * reset initialized status
    * reset initialized status
    */
    */
@@ -65,40 +58,8 @@ export default class PageEditorByHackmd extends React.PureComponent {
     this.setState({ isInitialized: false });
     this.setState({ isInitialized: false });
   }
   }
 
 
-  /**
-   * clear revision status (invoked when page is updated by myself)
-   */
-  clearRevisionStatus(updatedRevisionId, updatedRevisionIdHackmdSynced) {
-    this.setState({
-      initialRevisionId: updatedRevisionId,
-      revisionId: updatedRevisionId,
-      revisionIdHackmdSynced: updatedRevisionIdHackmdSynced,
-    });
-  }
-
-  /**
-   * update revisionId of state
-   * @param {string} revisionId
-   * @param {string} revisionIdHackmdSynced
-   */
-  setRevisionId(revisionId, revisionIdHackmdSynced) {
-    this.setState({ revisionId, revisionIdHackmdSynced });
-  }
-
-  getRevisionIdHackmdSynced() {
-    return this.state.revisionIdHackmdSynced;
-  }
-
-  /**
-   * update hasDraftOnHackmd of state
-   * @param {bool} hasDraftOnHackmd
-   */
-  setHasDraftOnHackmd(hasDraftOnHackmd) {
-    this.setState({ hasDraftOnHackmd });
-  }
-
   getHackmdUri() {
   getHackmdUri() {
-    const envVars = this.props.crowi.config.env;
+    const envVars = this.props.appContainer.getConfig().env;
     return envVars.HACKMD_URI;
     return envVars.HACKMD_URI;
   }
   }
 
 
@@ -106,6 +67,7 @@ export default class PageEditorByHackmd extends React.PureComponent {
    * Start integration with HackMD
    * Start integration with HackMD
    */
    */
   startToEdit() {
   startToEdit() {
+    const { pageContainer } = this.props;
     const hackmdUri = this.getHackmdUri();
     const hackmdUri = this.getHackmdUri();
 
 
     if (hackmdUri == null) {
     if (hackmdUri == null) {
@@ -119,9 +81,9 @@ export default class PageEditorByHackmd extends React.PureComponent {
     });
     });
 
 
     const params = {
     const params = {
-      pageId: this.props.pageId,
+      pageId: pageContainer.state.pageId,
     };
     };
-    this.props.crowi.apiPost('/hackmd.integrate', params)
+    this.props.appContainer.apiPost('/hackmd.integrate', params)
       .then((res) => {
       .then((res) => {
         if (!res.ok) {
         if (!res.ok) {
           throw new Error(res.error);
           throw new Error(res.error);
@@ -129,6 +91,8 @@ export default class PageEditorByHackmd extends React.PureComponent {
 
 
         this.setState({
         this.setState({
           isInitialized: true,
           isInitialized: true,
+        });
+        pageContainer.setState({
           pageIdOnHackmd: res.pageIdOnHackmd,
           pageIdOnHackmd: res.pageIdOnHackmd,
           revisionIdHackmdSynced: res.revisionIdHackmdSynced,
           revisionIdHackmdSynced: res.revisionIdHackmdSynced,
         });
         });
@@ -150,7 +114,7 @@ export default class PageEditorByHackmd extends React.PureComponent {
    * Reset draft
    * Reset draft
    */
    */
   discardChanges() {
   discardChanges() {
-    this.setState({ hasDraftOnHackmd: false });
+    this.props.pageContainer.setState({ hasDraftOnHackmd: false });
   }
   }
 
 
   /**
   /**
@@ -158,6 +122,7 @@ export default class PageEditorByHackmd extends React.PureComponent {
    */
    */
   hackmdEditorChangeHandler(body) {
   hackmdEditorChangeHandler(body) {
     const hackmdUri = this.getHackmdUri();
     const hackmdUri = this.getHackmdUri();
+    const { pageContainer } = this.props;
 
 
     if (hackmdUri == null) {
     if (hackmdUri == null) {
       // do nothing
       // do nothing
@@ -165,14 +130,14 @@ export default class PageEditorByHackmd extends React.PureComponent {
     }
     }
 
 
     // do nothing if contents are same
     // do nothing if contents are same
-    if (this.props.markdown === body) {
+    if (pageContainer.state.markdown === body) {
       return;
       return;
     }
     }
 
 
     const params = {
     const params = {
-      pageId: this.props.pageId,
+      pageId: pageContainer.state.pageId,
     };
     };
-    this.props.crowi.apiPost('/hackmd.saveOnHackmd', params)
+    this.props.appContainer.apiPost('/hackmd.saveOnHackmd', params)
       .then((res) => {
       .then((res) => {
         // do nothing
         // do nothing
       })
       })
@@ -194,16 +159,20 @@ export default class PageEditorByHackmd extends React.PureComponent {
 
 
   render() {
   render() {
     const hackmdUri = this.getHackmdUri();
     const hackmdUri = this.getHackmdUri();
+    const { pageContainer } = this.props;
+    const {
+      pageIdOnHackmd, revisionId, revisionIdHackmdSynced, remoteRevisionId, hasDraftOnHackmd,
+    } = pageContainer.state;
 
 
-    const isPageExistsOnHackmd = (this.state.pageIdOnHackmd != null);
-    const isResume = isPageExistsOnHackmd && this.state.hasDraftOnHackmd;
+    const isPageExistsOnHackmd = (pageIdOnHackmd != null);
+    const isResume = isPageExistsOnHackmd && hasDraftOnHackmd;
 
 
     if (this.state.isInitialized) {
     if (this.state.isInitialized) {
       return (
       return (
         <HackmdEditor
         <HackmdEditor
           ref={(c) => { this.hackmdEditor = c }}
           ref={(c) => { this.hackmdEditor = c }}
           hackmdUri={hackmdUri}
           hackmdUri={hackmdUri}
-          pageIdOnHackmd={this.state.pageIdOnHackmd}
+          pageIdOnHackmd={pageIdOnHackmd}
           initializationMarkdown={isResume ? null : this.state.markdown}
           initializationMarkdown={isResume ? null : this.state.markdown}
           onChange={this.hackmdEditorChangeHandler}
           onChange={this.hackmdEditorChangeHandler}
           onSaveWithShortcut={(document) => {
           onSaveWithShortcut={(document) => {
@@ -214,8 +183,8 @@ export default class PageEditorByHackmd extends React.PureComponent {
       );
       );
     }
     }
 
 
-    const isRevisionOutdated = this.state.initialRevisionId !== this.state.revisionId;
-    const isHackmdDocumentOutdated = this.state.revisionId !== this.state.revisionIdHackmdSynced;
+    const isRevisionOutdated = revisionId !== remoteRevisionId;
+    const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
 
 
     let content;
     let content;
     /*
     /*
@@ -232,7 +201,6 @@ export default class PageEditorByHackmd extends React.PureComponent {
      * Resume to edit or discard changes
      * Resume to edit or discard changes
      */
      */
     else if (isResume) {
     else if (isResume) {
-      const revisionIdHackmdSynced = this.state.revisionIdHackmdSynced;
       const title = (
       const title = (
         <React.Fragment>
         <React.Fragment>
           <span className="btn-label"><i className="icon-control-end"></i></span>
           <span className="btn-label"><i className="icon-control-end"></i></span>
@@ -320,13 +288,18 @@ export default class PageEditorByHackmd extends React.PureComponent {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const PageEditorByHackmdWrapper = (props) => {
+  return createSubscribedElement(PageEditorByHackmd, props, [AppContainer, PageContainer]);
+};
+
 PageEditorByHackmd.propTypes = {
 PageEditorByHackmd.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  markdown: PropTypes.string.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
   onSaveWithShortcut: PropTypes.func.isRequired,
   onSaveWithShortcut: PropTypes.func.isRequired,
-  pageId: PropTypes.string,
-  revisionId: PropTypes.string,
-  pageIdOnHackmd: PropTypes.string,
-  revisionIdHackmdSynced: PropTypes.string,
-  hasDraftOnHackmd: PropTypes.bool,
 };
 };
+
+export default PageEditorByHackmdWrapper;

+ 15 - 4
src/client/js/components/PageList/Draft.jsx

@@ -3,7 +3,9 @@ import PropTypes from 'prop-types';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
+import { createSubscribedElement } from '../UnstatedUtils';
 import GrowiRenderer from '../../util/GrowiRenderer';
 import GrowiRenderer from '../../util/GrowiRenderer';
+import AppContainer from '../../services/AppContainer';
 
 
 import RevisionBody from '../Page/RevisionBody';
 import RevisionBody from '../Page/RevisionBody';
 
 
@@ -17,7 +19,7 @@ class Draft extends React.Component {
       isOpen: false,
       isOpen: false,
     };
     };
 
 
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, { mode: 'draft' });
+    this.growiRenderer = new GrowiRenderer(window.crowi, this.props.crowiOriginRenderer, { mode: 'draft' });
 
 
     this.renderHtml = this.renderHtml.bind(this);
     this.renderHtml = this.renderHtml.bind(this);
     this.toggleContent = this.toggleContent.bind(this);
     this.toggleContent = this.toggleContent.bind(this);
@@ -52,7 +54,7 @@ class Draft extends React.Component {
     };
     };
 
 
     const growiRenderer = this.growiRenderer;
     const growiRenderer = this.growiRenderer;
-    const interceptorManager = this.props.crowi.interceptorManager;
+    const interceptorManager = this.props.appContainer.interceptorManager;
     await interceptorManager.process('prePreProcess', context)
     await interceptorManager.process('prePreProcess', context)
       .then(() => {
       .then(() => {
         context.markdown = growiRenderer.preProcess(context.markdown);
         context.markdown = growiRenderer.preProcess(context.markdown);
@@ -154,9 +156,18 @@ class Draft extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const DraftWrapper = (props) => {
+  return createSubscribedElement(Draft, props, [AppContainer]);
+};
+
+
 Draft.propTypes = {
 Draft.propTypes = {
   t: PropTypes.func.isRequired,
   t: PropTypes.func.isRequired,
-  crowi: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   crowiOriginRenderer: PropTypes.object.isRequired,
   crowiOriginRenderer: PropTypes.object.isRequired,
   path: PropTypes.string.isRequired,
   path: PropTypes.string.isRequired,
   markdown: PropTypes.string.isRequired,
   markdown: PropTypes.string.isRequired,
@@ -164,4 +175,4 @@ Draft.propTypes = {
   clearDraft: PropTypes.func.isRequired,
   clearDraft: PropTypes.func.isRequired,
 };
 };
 
 
-export default withTranslation()(Draft);
+export default withTranslation()(DraftWrapper);

+ 1 - 3
src/client/js/components/PageList/PagePath.js

@@ -30,7 +30,7 @@ export default class PagePath extends React.Component {
   render() {
   render() {
     const page = this.props.page;
     const page = this.props.page;
     const isShortPathOnly = this.props.isShortPathOnly;
     const isShortPathOnly = this.props.isShortPathOnly;
-    const pagePath = decodeURIComponent(page.path.replace(this.props.excludePathString.replace(/^\//, ''), ''));
+    const pagePath = decodeURIComponent(page.path);
     const shortPath = this.getShortPath(pagePath);
     const shortPath = this.getShortPath(pagePath);
 
 
     const shortPathEscaped = escapeStringRegexp(shortPath);
     const shortPathEscaped = escapeStringRegexp(shortPath);
@@ -51,11 +51,9 @@ export default class PagePath extends React.Component {
 PagePath.propTypes = {
 PagePath.propTypes = {
   page: PropTypes.object.isRequired,
   page: PropTypes.object.isRequired,
   isShortPathOnly: PropTypes.bool,
   isShortPathOnly: PropTypes.bool,
-  excludePathString: PropTypes.string,
   additionalClassNames: PropTypes.array,
   additionalClassNames: PropTypes.array,
 };
 };
 
 
 PagePath.defaultProps = {
 PagePath.defaultProps = {
   additionalClassNames: [],
   additionalClassNames: [],
-  excludePathString: '',
 };
 };

+ 30 - 43
src/client/js/components/PageStatusAlert.jsx

@@ -1,7 +1,13 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
+import AppContainer from '../services/AppContainer';
+import PageContainer from '../services/PageContainer';
+
+import { createSubscribedElement } from './UnstatedUtils';
+
 /**
 /**
  *
  *
  * @author Yuki Takei <yuki@weseek.co.jp>
  * @author Yuki Takei <yuki@weseek.co.jp>
@@ -17,12 +23,6 @@ class PageStatusAlert extends React.Component {
     super(props);
     super(props);
 
 
     this.state = {
     this.state = {
-      initialRevisionId: this.props.revisionId,
-      revisionId: this.props.revisionId,
-      revisionIdHackmdSynced: this.props.revisionIdHackmdSynced,
-      lastUpdateUsername: undefined,
-      hasDraftOnHackmd: this.props.hasDraftOnHackmd,
-      isDraftUpdatingInRealtime: false,
     };
     };
 
 
     this.renderSomeoneEditingAlert = this.renderSomeoneEditingAlert.bind(this);
     this.renderSomeoneEditingAlert = this.renderSomeoneEditingAlert.bind(this);
@@ -30,32 +30,8 @@ class PageStatusAlert extends React.Component {
     this.renderUpdatedAlert = this.renderUpdatedAlert.bind(this);
     this.renderUpdatedAlert = this.renderUpdatedAlert.bind(this);
   }
   }
 
 
-  /**
-   * clear status (invoked when page is updated by myself)
-   */
-  clearRevisionStatus(updatedRevisionId, updatedRevisionIdHackmdSynced) {
-    this.setState({
-      initialRevisionId: updatedRevisionId,
-      revisionId: updatedRevisionId,
-      revisionIdHackmdSynced: updatedRevisionIdHackmdSynced,
-      hasDraftOnHackmd: false,
-      isDraftUpdatingInRealtime: false,
-    });
-  }
-
-  setRevisionId(revisionId, revisionIdHackmdSynced) {
-    this.setState({ revisionId, revisionIdHackmdSynced });
-  }
-
-  setLastUpdateUsername(lastUpdateUsername) {
-    this.setState({ lastUpdateUsername });
-  }
-
-  setHasDraftOnHackmd(hasDraftOnHackmd) {
-    this.setState({
-      hasDraftOnHackmd,
-      isDraftUpdatingInRealtime: true,
-    });
+  componentWillMount() {
+    this.props.appContainer.registerComponentInstance(this);
   }
   }
 
 
   refreshPage() {
   refreshPage() {
@@ -100,7 +76,7 @@ class PageStatusAlert extends React.Component {
     return (
     return (
       <div className="alert-revision-outdated myadmin-alert alert-warning myadmin-alert-bottom alertbottom2">
       <div className="alert-revision-outdated myadmin-alert alert-warning myadmin-alert-bottom alertbottom2">
         <i className="icon-fw icon-bulb"></i>
         <i className="icon-fw icon-bulb"></i>
-        {this.state.lastUpdateUsername} {label1}
+        {this.props.pageContainer.state.lastUpdateUsername} {label1}
         &nbsp;
         &nbsp;
         <i className="fa fa-angle-double-right"></i>
         <i className="fa fa-angle-double-right"></i>
         &nbsp;
         &nbsp;
@@ -114,16 +90,23 @@ class PageStatusAlert extends React.Component {
   render() {
   render() {
     let content = <React.Fragment></React.Fragment>;
     let content = <React.Fragment></React.Fragment>;
 
 
-    const isRevisionOutdated = this.state.initialRevisionId !== this.state.revisionId;
-    const isHackmdDocumentOutdated = this.state.revisionId !== this.state.revisionIdHackmdSynced;
+    const {
+      revisionId, revisionIdHackmdSynced, remoteRevisionId, hasDraftOnHackmd, isHackmdDraftUpdatingInRealtime,
+    } = this.props.pageContainer.state;
+
+    const isRevisionOutdated = revisionId !== remoteRevisionId;
+    const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
 
 
+    // when remote revision is newer than both
     if (isHackmdDocumentOutdated && isRevisionOutdated) {
     if (isHackmdDocumentOutdated && isRevisionOutdated) {
       content = this.renderUpdatedAlert();
       content = this.renderUpdatedAlert();
     }
     }
-    else if (this.state.isDraftUpdatingInRealtime) {
+    // when someone editing with HackMD
+    else if (isHackmdDraftUpdatingInRealtime) {
       content = this.renderSomeoneEditingAlert();
       content = this.renderSomeoneEditingAlert();
     }
     }
-    else if (this.state.hasDraftOnHackmd) {
+    // when the draft of HackMD is newest
+    else if (hasDraftOnHackmd) {
       content = this.renderDraftExistsAlert();
       content = this.renderDraftExistsAlert();
     }
     }
 
 
@@ -132,14 +115,18 @@ class PageStatusAlert extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const PageStatusAlertWrapper = (props) => {
+  return createSubscribedElement(PageStatusAlert, props, [AppContainer, PageContainer]);
+};
+
 PageStatusAlert.propTypes = {
 PageStatusAlert.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
-  hasDraftOnHackmd: PropTypes.bool.isRequired,
-  revisionId: PropTypes.string,
-  revisionIdHackmdSynced: PropTypes.string,
-};
 
 
-PageStatusAlert.defaultProps = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 };
 
 
-export default withTranslation(null, { withRef: true })(PageStatusAlert);
+export default withTranslation()(PageStatusAlertWrapper);

+ 22 - 11
src/client/js/components/RecentCreated/RecentCreated.jsx

@@ -1,10 +1,15 @@
 import React from 'react';
 import React from 'react';
-
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+
 import Pagination from 'react-bootstrap/lib/Pagination';
 import Pagination from 'react-bootstrap/lib/Pagination';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+import PageContainer from '../../services/PageContainer';
+
 import Page from '../PageList/Page';
 import Page from '../PageList/Page';
 
 
-export default class RecentCreated extends React.Component {
+class RecentCreated extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -23,13 +28,15 @@ export default class RecentCreated extends React.Component {
   }
   }
 
 
   getRecentCreatedList(selectPageNumber) {
   getRecentCreatedList(selectPageNumber) {
-    const pageId = this.props.pageId;
-    const userId = this.props.crowi.me;
-    const limit = this.props.limit;
+    const { appContainer, pageContainer } = this.props;
+    const { pageId } = pageContainer.state;
+
+    const userId = appContainer.me;
+    const limit = appContainer.getConfig().recentCreatedLimit;
     const offset = (selectPageNumber - 1) * limit;
     const offset = (selectPageNumber - 1) * limit;
 
 
     // pagesList get and pagination calculate
     // pagesList get and pagination calculate
-    this.props.crowi.apiGet('/pages.recentCreated', {
+    this.props.appContainer.apiGet('/pages.recentCreated', {
       page_id: pageId, user: userId, limit, offset,
       page_id: pageId, user: userId, limit, offset,
     })
     })
       .then((res) => {
       .then((res) => {
@@ -183,12 +190,16 @@ export default class RecentCreated extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const RecentCreatedWrapper = (props) => {
+  return createSubscribedElement(RecentCreated, props, [AppContainer, PageContainer]);
+};
 
 
 RecentCreated.propTypes = {
 RecentCreated.propTypes = {
-  pageId: PropTypes.string.isRequired,
-  crowi: PropTypes.object.isRequired,
-  limit: PropTypes.number,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 };
 
 
-RecentCreated.defaultProps = {
-};
+export default RecentCreatedWrapper;

+ 43 - 44
src/client/js/components/SavePageControls.jsx

@@ -1,53 +1,52 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
 import ButtonToolbar from 'react-bootstrap/es/ButtonToolbar';
 import ButtonToolbar from 'react-bootstrap/es/ButtonToolbar';
 import SplitButton from 'react-bootstrap/es/SplitButton';
 import SplitButton from 'react-bootstrap/es/SplitButton';
 import MenuItem from 'react-bootstrap/es/MenuItem';
 import MenuItem from 'react-bootstrap/es/MenuItem';
 
 
+import PageContainer from '../services/PageContainer';
+import AppContainer from '../services/AppContainer';
+import EditorContainer from '../services/EditorContainer';
+
+import { createSubscribedElement } from './UnstatedUtils';
 import SlackNotification from './SlackNotification';
 import SlackNotification from './SlackNotification';
 import GrantSelector from './SavePageControls/GrantSelector';
 import GrantSelector from './SavePageControls/GrantSelector';
 
 
-class SavePageControls extends React.PureComponent {
+
+class SavePageControls extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
-    this.state = {
-      pageId: this.props.pageId,
-    };
-
-    const config = this.props.crowi.getConfig();
+    const config = this.props.appContainer.getConfig();
     this.hasSlackConfig = config.hasSlackConfig;
     this.hasSlackConfig = config.hasSlackConfig;
     this.isAclEnabled = config.isAclEnabled;
     this.isAclEnabled = config.isAclEnabled;
 
 
-    this.getCurrentOptionsToSave = this.getCurrentOptionsToSave.bind(this);
+    this.slackEnabledFlagChangedHandler = this.slackEnabledFlagChangedHandler.bind(this);
+    this.slackChannelsChangedHandler = this.slackChannelsChangedHandler.bind(this);
+    this.updateGrantHandler = this.updateGrantHandler.bind(this);
+
     this.submit = this.submit.bind(this);
     this.submit = this.submit.bind(this);
     this.submitAndOverwriteScopesOfDescendants = this.submitAndOverwriteScopesOfDescendants.bind(this);
     this.submitAndOverwriteScopesOfDescendants = this.submitAndOverwriteScopesOfDescendants.bind(this);
   }
   }
 
 
-  componentWillMount() {
+  slackEnabledFlagChangedHandler(isSlackEnabled) {
+    this.props.editorContainer.setState({ isSlackEnabled });
   }
   }
 
 
-  getCurrentOptionsToSave() {
-    let currentOptions = this.grantSelector.getCurrentOptionsToSave();
-    if (this.hasSlackConfig) {
-      currentOptions = Object.assign(currentOptions, this.slackNotification.getCurrentOptionsToSave());
-    }
-    return currentOptions;
+  slackChannelsChangedHandler(slackChannels) {
+    this.props.editorContainer.setState({ slackChannels });
   }
   }
 
 
-  /**
-   * update pageId of state
-   * @param {string} pageId
-   */
-  setPageId(pageId) {
-    this.setState({ pageId });
+  updateGrantHandler(data) {
+    this.props.editorContainer.setState(data);
   }
   }
 
 
   submit() {
   submit() {
-    this.props.crowi.setIsDocSaved(true);
+    this.props.appContainer.setIsDocSaved(true);
     this.props.onSubmit();
     this.props.onSubmit();
   }
   }
 
 
@@ -56,8 +55,8 @@ class SavePageControls extends React.PureComponent {
   }
   }
 
 
   render() {
   render() {
-    const { t } = this.props;
-    const labelSubmitButton = this.state.pageId == null ? t('Create') : t('Update');
+    const { t, editorContainer } = this.props;
+    const labelSubmitButton = this.props.pageContainer.state.pageId == null ? t('Create') : t('Update');
     const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
     const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
 
 
     return (
     return (
@@ -66,9 +65,10 @@ class SavePageControls extends React.PureComponent {
           && (
           && (
           <div className="mr-2">
           <div className="mr-2">
             <SlackNotification
             <SlackNotification
-              ref={(c) => { this.slackNotification = c }}
-              isSlackEnabled={false}
-              slackChannels={this.props.slackChannels}
+              isSlackEnabled={editorContainer.state.isSlackEnabled}
+              slackChannels={editorContainer.state.slackChannels}
+              onEnabledFlagChange={this.slackEnabledFlagChangedHandler}
+              onChannelChange={this.slackChannelsChangedHandler}
             />
             />
           </div>
           </div>
           )
           )
@@ -78,15 +78,10 @@ class SavePageControls extends React.PureComponent {
           && (
           && (
           <div className="mr-2">
           <div className="mr-2">
             <GrantSelector
             <GrantSelector
-              crowi={this.props.crowi}
-              ref={(elem) => {
-                  if (this.grantSelector == null) {
-                    this.grantSelector = elem;
-                  }
-                }}
-              grant={this.props.grant}
-              grantGroupId={this.props.grantGroupId}
-              grantGroupName={this.props.grantGroupName}
+              grant={editorContainer.state.grant}
+              grantGroupId={editorContainer.state.grantGroupId}
+              grantGroupName={editorContainer.state.grantGroupName}
+              onUpdateGrant={this.updateGrantHandler}
             />
             />
           </div>
           </div>
           )
           )
@@ -112,17 +107,21 @@ class SavePageControls extends React.PureComponent {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const SavePageControlsWrapper = (props) => {
+  return createSubscribedElement(SavePageControls, props, [AppContainer, PageContainer, EditorContainer]);
+};
+
 SavePageControls.propTypes = {
 SavePageControls.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
+
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
   onSubmit: PropTypes.func.isRequired,
   onSubmit: PropTypes.func.isRequired,
-  pageId: PropTypes.string,
-  // for SlackNotification
-  slackChannels: PropTypes.string,
-  // for GrantSelector
-  grant: PropTypes.number,
-  grantGroupId: PropTypes.string,
-  grantGroupName: PropTypes.string,
 };
 };
 
 
-export default withTranslation(null, { withRef: true })(SavePageControls);
+export default withTranslation()(SavePageControlsWrapper);

+ 31 - 18
src/client/js/components/SavePageControls/GrantSelector.jsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
 import FormGroup from 'react-bootstrap/es/FormGroup';
 import FormGroup from 'react-bootstrap/es/FormGroup';
@@ -8,6 +9,10 @@ import ListGroup from 'react-bootstrap/es/ListGroup';
 import ListGroupItem from 'react-bootstrap/es/ListGroupItem';
 import ListGroupItem from 'react-bootstrap/es/ListGroupItem';
 import Modal from 'react-bootstrap/es/Modal';
 import Modal from 'react-bootstrap/es/Modal';
 
 
+import AppContainer from '../../services/AppContainer';
+
+import { createSubscribedElement } from '../UnstatedUtils';
+
 const SPECIFIED_GROUP_VALUE = 'specifiedGroup';
 const SPECIFIED_GROUP_VALUE = 'specifiedGroup';
 
 
 /**
 /**
@@ -42,11 +47,12 @@ class GrantSelector extends React.Component {
     ];
     ];
 
 
     this.state = {
     this.state = {
-      grant: this.props.grant || 1, // default: 1
       userRelatedGroups: [],
       userRelatedGroups: [],
       isSelectGroupModalShown: false,
       isSelectGroupModalShown: false,
+      grant: this.props.grant,
+      grantGroup: null,
     };
     };
-    if (this.props.grantGroupId !== '') {
+    if (this.props.grantGroupId != null) {
       this.state.grantGroup = {
       this.state.grantGroup = {
         _id: this.props.grantGroupId,
         _id: this.props.grantGroupId,
         name: this.props.grantGroupName,
         name: this.props.grantGroupName,
@@ -56,7 +62,6 @@ class GrantSelector extends React.Component {
     // retrieve xss library from window
     // retrieve xss library from window
     this.xss = window.xss;
     this.xss = window.xss;
 
 
-    this.getCurrentOptionsToSave = this.getCurrentOptionsToSave.bind(this);
     this.showSelectGroupModal = this.showSelectGroupModal.bind(this);
     this.showSelectGroupModal = this.showSelectGroupModal.bind(this);
     this.hideSelectGroupModal = this.hideSelectGroupModal.bind(this);
     this.hideSelectGroupModal = this.hideSelectGroupModal.bind(this);
 
 
@@ -85,16 +90,6 @@ class GrantSelector extends React.Component {
 
 
   }
   }
 
 
-  getCurrentOptionsToSave() {
-    const options = {
-      grant: this.state.grant,
-    };
-    if (this.state.grantGroup != null) {
-      options.grantUserGroupId = this.state.grantGroup._id;
-    }
-    return options;
-  }
-
   showSelectGroupModal() {
   showSelectGroupModal() {
     this.retrieveUserGroupRelations();
     this.retrieveUserGroupRelations();
     this.setState({ isSelectGroupModalShown: true });
     this.setState({ isSelectGroupModalShown: true });
@@ -113,7 +108,7 @@ class GrantSelector extends React.Component {
    * Retrieve user-group-relations data from backend
    * Retrieve user-group-relations data from backend
    */
    */
   retrieveUserGroupRelations() {
   retrieveUserGroupRelations() {
-    this.props.crowi.apiGet('/me/user-group-relations')
+    this.props.appContainer.apiGet('/me/user-group-relations')
       .then((res) => {
       .then((res) => {
         return res.userGroupRelations;
         return res.userGroupRelations;
       })
       })
@@ -142,11 +137,19 @@ class GrantSelector extends React.Component {
     }
     }
 
 
     this.setState({ grant, grantGroup: null });
     this.setState({ grant, grantGroup: null });
+
+    if (this.props.onUpdateGrant != null) {
+      this.props.onUpdateGrant({ grant, grantGroupId: null, grantGroupName: null });
+    }
   }
   }
 
 
   groupListItemClickHandler(grantGroup) {
   groupListItemClickHandler(grantGroup) {
     this.setState({ grant: 5, grantGroup });
     this.setState({ grant: 5, grantGroup });
 
 
+    if (this.props.onUpdateGrant != null) {
+      this.props.onUpdateGrant({ grant: 5, grantGroupId: grantGroup._id, grantGroupName: grantGroup.name });
+    }
+
     // hide modal
     // hide modal
     this.hideSelectGroupModal();
     this.hideSelectGroupModal();
   }
   }
@@ -239,7 +242,7 @@ class GrantSelector extends React.Component {
       ? (
       ? (
         <div>
         <div>
           <h4>There is no group to which you belong.</h4>
           <h4>There is no group to which you belong.</h4>
-          { this.props.crowi.isAdmin
+          { this.props.appContainer.isAdmin
             && <p><a href="/admin/user-groups"><i className="icon icon-fw icon-login"></i> Manage Groups</a></p>
             && <p><a href="/admin/user-groups"><i className="icon icon-fw icon-login"></i> Manage Groups</a></p>
           }
           }
         </div>
         </div>
@@ -280,12 +283,22 @@ class GrantSelector extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const GrantSelectorWrapper = (props) => {
+  return createSubscribedElement(GrantSelector, props, [AppContainer]);
+};
+
 GrantSelector.propTypes = {
 GrantSelector.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
-  grant: PropTypes.number,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
+  grant: PropTypes.number.isRequired,
   grantGroupId: PropTypes.string,
   grantGroupId: PropTypes.string,
   grantGroupName: PropTypes.string,
   grantGroupName: PropTypes.string,
+
+  onUpdateGrant: PropTypes.func,
 };
 };
 
 
-export default withTranslation(null, { withRef: true })(GrantSelector);
+export default withTranslation()(GrantSelectorWrapper);

+ 15 - 3
src/client/js/components/SearchForm.js

@@ -1,9 +1,12 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
+import { createSubscribedElement } from './UnstatedUtils';
+import AppContainer from '../services/AppContainer';
+
 import SearchTypeahead from './SearchTypeahead';
 import SearchTypeahead from './SearchTypeahead';
 
 
 // SearchTypeahead wrapper
 // SearchTypeahead wrapper
-export default class SearchForm extends React.Component {
+class SearchForm extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -93,7 +96,6 @@ export default class SearchForm extends React.Component {
 
 
     return (
     return (
       <SearchTypeahead
       <SearchTypeahead
-        crowi={this.props.crowi}
         onChange={this.onChange}
         onChange={this.onChange}
         onSubmit={this.props.onSubmit}
         onSubmit={this.props.onSubmit}
         onInputChange={this.props.onInputChange}
         onInputChange={this.props.onInputChange}
@@ -108,9 +110,17 @@ export default class SearchForm extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const SearchFormWrapper = (props) => {
+  return createSubscribedElement(SearchForm, props, [AppContainer]);
+};
+
 SearchForm.propTypes = {
 SearchForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   keyword: PropTypes.string,
   keyword: PropTypes.string,
   onSubmit: PropTypes.func.isRequired,
   onSubmit: PropTypes.func.isRequired,
   onInputChange: PropTypes.func,
   onInputChange: PropTypes.func,
@@ -119,3 +129,5 @@ SearchForm.propTypes = {
 SearchForm.defaultProps = {
 SearchForm.defaultProps = {
   onInputChange: () => {},
   onInputChange: () => {},
 };
 };
+
+export default SearchFormWrapper;

+ 14 - 7
src/client/js/components/SearchPage.js

@@ -4,6 +4,9 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
+import { createSubscribedElement } from './UnstatedUtils';
+import AppContainer from '../services/AppContainer';
+
 import SearchPageForm from './SearchPage/SearchPageForm';
 import SearchPageForm from './SearchPage/SearchPageForm';
 import SearchResult from './SearchPage/SearchResult';
 import SearchResult from './SearchPage/SearchResult';
 
 
@@ -69,7 +72,7 @@ class SearchPage extends React.Component {
       searchingKeyword: keyword,
       searchingKeyword: keyword,
     });
     });
 
 
-    this.props.crowi.apiGet('/search', { q: keyword })
+    this.props.appContainer.apiGet('/search', { q: keyword })
       .then((res) => {
       .then((res) => {
         this.changeURL(keyword);
         this.changeURL(keyword);
 
 
@@ -92,14 +95,11 @@ class SearchPage extends React.Component {
         <div className="search-page-input">
         <div className="search-page-input">
           <SearchPageForm
           <SearchPageForm
             t={this.props.t}
             t={this.props.t}
-            crowi={this.props.crowi}
             onSearchFormChanged={this.search}
             onSearchFormChanged={this.search}
             keyword={this.state.searchingKeyword}
             keyword={this.state.searchingKeyword}
           />
           />
         </div>
         </div>
         <SearchResult
         <SearchResult
-          crowi={this.props.crowi}
-          crowiRenderer={this.props.crowiRenderer}
           pages={this.state.searchedPages}
           pages={this.state.searchedPages}
           searchingKeyword={this.state.searchingKeyword}
           searchingKeyword={this.state.searchingKeyword}
           searchResultMeta={this.state.searchResultMeta}
           searchResultMeta={this.state.searchResultMeta}
@@ -110,10 +110,17 @@ class SearchPage extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const SearchPageWrapper = (props) => {
+  return createSubscribedElement(SearchPage, props, [AppContainer]);
+};
+
 SearchPage.propTypes = {
 SearchPage.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   query: PropTypes.object,
   query: PropTypes.object,
 };
 };
 SearchPage.defaultProps = {
 SearchPage.defaultProps = {
@@ -121,4 +128,4 @@ SearchPage.defaultProps = {
   query: SearchPage.getQueryByLocation(window.location || {}),
   query: SearchPage.getQueryByLocation(window.location || {}),
 };
 };
 
 
-export default withTranslation()(SearchPage);
+export default withTranslation()(SearchPageWrapper);

+ 15 - 3
src/client/js/components/SearchPage/SearchPageForm.js

@@ -5,10 +5,13 @@ import FormGroup from 'react-bootstrap/es/FormGroup';
 import Button from 'react-bootstrap/es/Button';
 import Button from 'react-bootstrap/es/Button';
 import InputGroup from 'react-bootstrap/es/InputGroup';
 import InputGroup from 'react-bootstrap/es/InputGroup';
 
 
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+
 import SearchForm from '../SearchForm';
 import SearchForm from '../SearchForm';
 
 
 // Search.SearchForm
 // Search.SearchForm
-export default class SearchPageForm extends React.Component {
+class SearchPageForm extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -38,7 +41,6 @@ export default class SearchPageForm extends React.Component {
         <InputGroup>
         <InputGroup>
           <SearchForm
           <SearchForm
             t={this.props.t}
             t={this.props.t}
-            crowi={this.props.crowi}
             onSubmit={this.search}
             onSubmit={this.search}
             keyword={this.state.searchedKeyword}
             keyword={this.state.searchedKeyword}
             onInputChange={this.onInputChange}
             onInputChange={this.onInputChange}
@@ -55,11 +57,21 @@ export default class SearchPageForm extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const SearchPageFormWrapper = (props) => {
+  return createSubscribedElement(SearchPageForm, props, [AppContainer]);
+};
+
 SearchPageForm.propTypes = {
 SearchPageForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
-  crowi: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   keyword: PropTypes.string,
   keyword: PropTypes.string,
   onSearchFormChanged: PropTypes.func.isRequired,
   onSearchFormChanged: PropTypes.func.isRequired,
 };
 };
 SearchPageForm.defaultProps = {
 SearchPageForm.defaultProps = {
 };
 };
+
+export default SearchPageFormWrapper;

+ 26 - 22
src/client/js/components/SearchPage/SearchResult.js

@@ -7,9 +7,10 @@ import * as toastr from 'toastr';
 import Page from '../PageList/Page';
 import Page from '../PageList/Page';
 import SearchResultList from './SearchResultList';
 import SearchResultList from './SearchResultList';
 import DeletePageListModal from './DeletePageListModal';
 import DeletePageListModal from './DeletePageListModal';
+import AppContainer from '../../services/AppContainer';
+import { createSubscribedElement } from '../UnstatedUtils';
 
 
-// Search.SearchResult
-export default class SearchResult extends React.Component {
+class SearchResult extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -117,7 +118,8 @@ export default class SearchResult extends React.Component {
       return new Promise((resolve, reject) => {
       return new Promise((resolve, reject) => {
         const pageId = page._id;
         const pageId = page._id;
         const revisionId = page.revision._id;
         const revisionId = page.revision._id;
-        this.props.crowi.apiPost('/pages.remove', { page_id: pageId, revision_id: revisionId, completely: deleteCompletely })
+
+        this.props.appContainer.apiPost('/pages.remove', { page_id: pageId, revision_id: revisionId, completely: deleteCompletely })
           .then((res) => {
           .then((res) => {
             if (res.ok) {
             if (res.ok) {
               this.state.selectedPages.delete(page);
               this.state.selectedPages.delete(page);
@@ -171,10 +173,6 @@ export default class SearchResult extends React.Component {
   }
   }
 
 
   render() {
   render() {
-    const excludePathString = this.props.tree;
-
-    // console.log(this.props.searchError);
-    // console.log(this.isError());
     if (this.isError()) {
     if (this.isError()) {
       return (
       return (
         <div className="content-main">
         <div className="content-main">
@@ -189,7 +187,7 @@ export default class SearchResult extends React.Component {
 
 
     if (this.isNotFound()) {
     if (this.isNotFound()) {
       let under = '';
       let under = '';
-      if (this.props.tree !== '') {
+      if (this.props.tree != null) {
         under = ` under "${this.props.tree}"`;
         under = ` under "${this.props.tree}"`;
       }
       }
       return (
       return (
@@ -249,18 +247,17 @@ export default class SearchResult extends React.Component {
           page={page}
           page={page}
           linkTo={pageId}
           linkTo={pageId}
           key={page._id}
           key={page._id}
-          excludePathString={excludePathString}
         >
         >
           { this.state.deletionMode
           { this.state.deletionMode
             && (
             && (
-            <input
-              type="checkbox"
-              className="search-result-list-delete-checkbox"
-              value={pageId}
-              checked={this.state.selectedPages.has(page)}
-              onClick={() => { return this.toggleCheckbox(page) }}
-            />
-)
+              <input
+                type="checkbox"
+                className="search-result-list-delete-checkbox"
+                value={pageId}
+                checked={this.state.selectedPages.has(page)}
+                onClick={() => { return this.toggleCheckbox(page) }}
+              />
+            )
             }
             }
           <div className="page-list-option">
           <div className="page-list-option">
             <a href={page.path}><i className="icon-login" /></a>
             <a href={page.path}><i className="icon-login" /></a>
@@ -300,8 +297,6 @@ export default class SearchResult extends React.Component {
           </div>
           </div>
           <div className="col-md-8 search-result-content" id="search-result-content">
           <div className="col-md-8 search-result-content" id="search-result-content">
             <SearchResultList
             <SearchResultList
-              crowi={this.props.crowi}
-              crowiRenderer={this.props.crowiRenderer}
               pages={this.props.pages}
               pages={this.props.pages}
               searchingKeyword={this.props.searchingKeyword}
               searchingKeyword={this.props.searchingKeyword}
             />
             />
@@ -322,15 +317,24 @@ export default class SearchResult extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const SearchResultWrapper = (props) => {
+  return createSubscribedElement(SearchResult, props, [AppContainer]);
+};
+
 SearchResult.propTypes = {
 SearchResult.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object,
-  tree: PropTypes.string.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   pages: PropTypes.array.isRequired,
   pages: PropTypes.array.isRequired,
   searchingKeyword: PropTypes.string.isRequired,
   searchingKeyword: PropTypes.string.isRequired,
   searchResultMeta: PropTypes.object.isRequired,
   searchResultMeta: PropTypes.object.isRequired,
   searchError: PropTypes.object,
   searchError: PropTypes.object,
+  tree: PropTypes.string,
 };
 };
 SearchResult.defaultProps = {
 SearchResult.defaultProps = {
   searchError: null,
   searchError: null,
 };
 };
+
+export default SearchResultWrapper;

+ 16 - 8
src/client/js/components/SearchPage/SearchResultList.js

@@ -1,16 +1,16 @@
 import React from 'react';
 import React from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import GrowiRenderer from '../../util/GrowiRenderer';
-
 import RevisionLoader from '../Page/RevisionLoader';
 import RevisionLoader from '../Page/RevisionLoader';
+import AppContainer from '../../services/AppContainer';
+import { createSubscribedElement } from '../UnstatedUtils';
 
 
-export default class SearchResultList extends React.Component {
+class SearchResultList extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
-    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiRenderer, { mode: 'searchresult' });
+    this.growiRenderer = this.props.appContainer.getRenderer('searchresult');
   }
   }
 
 
   render() {
   render() {
@@ -22,8 +22,7 @@ export default class SearchResultList extends React.Component {
             <span><i className="tag-icon icon-tag"></i> {page.tags.join(', ')}</span>
             <span><i className="tag-icon icon-tag"></i> {page.tags.join(', ')}</span>
           )}
           )}
           <RevisionLoader
           <RevisionLoader
-            crowi={this.props.crowi}
-            crowiRenderer={this.growiRenderer}
+            growiRenderer={this.growiRenderer}
             pageId={page._id}
             pageId={page._id}
             pagePath={page.path}
             pagePath={page.path}
             revisionId={page.revision}
             revisionId={page.revision}
@@ -42,12 +41,21 @@ export default class SearchResultList extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const SearchResultListWrapper = (props) => {
+  return createSubscribedElement(SearchResultList, props, [AppContainer]);
+};
+
 SearchResultList.propTypes = {
 SearchResultList.propTypes = {
-  crowi: PropTypes.object.isRequired,
-  crowiRenderer: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   pages: PropTypes.array.isRequired,
   pages: PropTypes.array.isRequired,
   searchingKeyword: PropTypes.string.isRequired,
   searchingKeyword: PropTypes.string.isRequired,
 };
 };
 
 
 SearchResultList.defaultProps = {
 SearchResultList.defaultProps = {
 };
 };
+
+export default SearchResultListWrapper;

+ 15 - 4
src/client/js/components/SearchTypeahead.js

@@ -7,8 +7,10 @@ import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 import UserPicture from './User/UserPicture';
 import UserPicture from './User/UserPicture';
 import PageListMeta from './PageList/PageListMeta';
 import PageListMeta from './PageList/PageListMeta';
 import PagePath from './PageList/PagePath';
 import PagePath from './PageList/PagePath';
+import AppContainer from '../services/AppContainer';
+import { createSubscribedElement } from './UnstatedUtils';
 
 
-export default class SearchTypeahead extends React.Component {
+class SearchTypeahead extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
 
 
@@ -20,7 +22,6 @@ export default class SearchTypeahead extends React.Component {
       isLoading: false,
       isLoading: false,
       searchError: null,
       searchError: null,
     };
     };
-    this.crowi = this.props.crowi;
 
 
     this.restoreInitialData = this.restoreInitialData.bind(this);
     this.restoreInitialData = this.restoreInitialData.bind(this);
     this.search = this.search.bind(this);
     this.search = this.search.bind(this);
@@ -68,7 +69,7 @@ export default class SearchTypeahead extends React.Component {
 
 
     this.setState({ isLoading: true });
     this.setState({ isLoading: true });
 
 
-    this.crowi.apiGet('/search', { q: keyword })
+    this.props.appContainer.apiGet('/search', { q: keyword })
       .then((res) => { this.onSearchSuccess(res) })
       .then((res) => { this.onSearchSuccess(res) })
       .catch((err) => { this.onSearchError(err) });
       .catch((err) => { this.onSearchError(err) });
   }
   }
@@ -205,11 +206,19 @@ export default class SearchTypeahead extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const SearchTypeaheadWrapper = (props) => {
+  return createSubscribedElement(SearchTypeahead, props, [AppContainer]);
+};
+
 /**
 /**
  * Properties
  * Properties
  */
  */
 SearchTypeahead.propTypes = {
 SearchTypeahead.propTypes = {
-  crowi:           PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   onSearchSuccess: PropTypes.func,
   onSearchSuccess: PropTypes.func,
   onSearchError:   PropTypes.func,
   onSearchError:   PropTypes.func,
   onChange:        PropTypes.func,
   onChange:        PropTypes.func,
@@ -234,3 +243,5 @@ SearchTypeahead.defaultProps = {
   keywordOnInit:   '',
   keywordOnInit:   '',
   onInputChange: () => {},
   onInputChange: () => {},
 };
 };
+
+export default SearchTypeaheadWrapper;

+ 22 - 38
src/client/js/components/SlackNotification.jsx

@@ -15,62 +15,50 @@ export default class SlackNotification extends React.Component {
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
-    this.state = {
-      isSlackEnabled: this.props.isSlackEnabled,
-      slackChannels: this.props.slackChannels,
-    };
-
-    this.updateState = this.updateState.bind(this);
-    this.updateStateCheckbox = this.updateStateCheckbox.bind(this);
-  }
-
-  componentWillReceiveProps(nextProps) {
-    this.setState({
-      isSlackEnabled: nextProps.isSlackEnabled,
-      slackChannels: nextProps.slackChannels,
-    });
+    this.updateCheckboxHandler = this.updateCheckboxHandler.bind(this);
+    this.updateSlackChannelsHandler = this.updateSlackChannelsHandler.bind(this);
   }
   }
 
 
-  getCurrentOptionsToSave() {
-    return Object.assign({}, this.state);
+  updateCheckboxHandler(event) {
+    const value = event.target.checked;
+    if (this.props.onEnabledFlagChange != null) {
+      this.props.onEnabledFlagChange(value);
+    }
   }
   }
 
 
-  updateState(value) {
-    this.setState({ slackChannels: value });
-    // dispatch event
+  updateSlackChannelsHandler(event) {
+    const value = event.target.value;
     if (this.props.onChannelChange != null) {
     if (this.props.onChannelChange != null) {
       this.props.onChannelChange(value);
       this.props.onChannelChange(value);
     }
     }
   }
   }
 
 
-  updateStateCheckbox(event) {
-    const value = event.target.checked;
-    this.setState({ isSlackEnabled: value });
-    // dispatch event
-    if (this.props.onEnabledFlagChange != null) {
-      this.props.onEnabledFlagChange(value);
-    }
-  }
-
   render() {
   render() {
     return (
     return (
       <div className="input-group input-group-sm input-group-slack extended-setting">
       <div className="input-group input-group-sm input-group-slack extended-setting">
         <label className="input-group-addon">
         <label className="input-group-addon">
           <img id="slack-mark-white" alt="slack-mark" src="/images/icons/slack/mark-monochrome_white.svg" width="18" height="18" />
           <img id="slack-mark-white" alt="slack-mark" src="/images/icons/slack/mark-monochrome_white.svg" width="18" height="18" />
           <img id="slack-mark-black" alt="slack-mark" src="/images/icons/slack/mark-monochrome_black.svg" width="18" height="18" />
           <img id="slack-mark-black" alt="slack-mark" src="/images/icons/slack/mark-monochrome_black.svg" width="18" height="18" />
-          <input type="checkbox" value="1" checked={this.state.isSlackEnabled} onChange={this.updateStateCheckbox} />
+
+          <input
+            type="checkbox"
+            value="1"
+            checked={this.props.isSlackEnabled}
+            onChange={this.updateCheckboxHandler}
+          />
+
         </label>
         </label>
         <input
         <input
           className="form-control"
           className="form-control"
           type="text"
           type="text"
-          value={this.state.slackChannels}
+          value={this.props.slackChannels}
           placeholder="slack channel name"
           placeholder="slack channel name"
           data-toggle="popover"
           data-toggle="popover"
           title="Slack通知"
           title="Slack通知"
           data-content="通知するにはチェックを入れてください。カンマ区切りで複数チャンネルに通知することができます。"
           data-content="通知するにはチェックを入れてください。カンマ区切りで複数チャンネルに通知することができます。"
           data-trigger="focus"
           data-trigger="focus"
           data-placement="top"
           data-placement="top"
-          onChange={(e) => { return this.updateState(e.target.value) }}
+          onChange={this.updateSlackChannelsHandler}
         />
         />
       </div>
       </div>
     );
     );
@@ -79,12 +67,8 @@ export default class SlackNotification extends React.Component {
 }
 }
 
 
 SlackNotification.propTypes = {
 SlackNotification.propTypes = {
-  isSlackEnabled: PropTypes.bool,
-  slackChannels: PropTypes.string,
-  onChannelChange: PropTypes.func,
+  isSlackEnabled: PropTypes.bool.isRequired,
+  slackChannels: PropTypes.string.isRequired,
   onEnabledFlagChange: PropTypes.func,
   onEnabledFlagChange: PropTypes.func,
-};
-
-SlackNotification.defaultProps = {
-  slackChannels: '',
+  onChannelChange: PropTypes.func,
 };
 };

+ 61 - 0
src/client/js/components/UnstatedUtils.jsx

@@ -0,0 +1,61 @@
+/* eslint-disable import/prefer-default-export */
+
+import React from 'react';
+import { Subscribe } from 'unstated';
+
+/**
+ * generate K/V object by specified instances
+ *
+ * @param {Array<object>} instances
+ * @returns automatically named key and value
+ *   e.g.
+ *   {
+ *     appContainer: <AppContainer />,
+ *     exampleContainer: <ExampleContainer />,
+ *   }
+ */
+function generateAutoNamedProps(instances) {
+  const props = {};
+
+  instances.forEach((instance) => {
+    // get class name
+    const className = instance.constructor.name;
+    // convert initial charactor to lower case
+    const propName = `${className.charAt(0).toLowerCase()}${className.slice(1)}`;
+
+    props[propName] = instance;
+  });
+
+  return props;
+}
+
+/**
+ * create React component instance that is injected specified containers
+ *
+ * @param {object} componentClass wrapped React.Component class
+ * @param {*} props
+ * @param {*} containerClasses unstated container classes to subscribe
+ * @returns returns such like a following element:
+ *  e.g.
+ *  <Subscribe to={containerClasses}>  // containerClasses = [AppContainer, PageContainer]
+ *    { (appContainer, pageContainer) => (
+ *      <Component appContainer={appContainer} pageContainer={pageContainer} {...this.props} />
+ *    )}
+ *  </Subscribe>
+ */
+export function createSubscribedElement(componentClass, props, containerClasses) {
+  return (
+    // wrap with <Subscribe></Subscribe>
+    <Subscribe to={containerClasses}>
+      { (...containers) => {
+        const propsForContainers = generateAutoNamedProps(containers);
+
+        return React.createElement(
+          componentClass,
+          Object.assign(propsForContainers, props),
+        );
+      }}
+    </Subscribe>
+  );
+
+}

+ 16 - 3
src/client/js/components/User/UserPictureList.jsx

@@ -4,9 +4,12 @@ import PropTypes from 'prop-types';
 import OverlayTrigger from 'react-bootstrap/es/OverlayTrigger';
 import OverlayTrigger from 'react-bootstrap/es/OverlayTrigger';
 import Tooltip from 'react-bootstrap/es/Tooltip';
 import Tooltip from 'react-bootstrap/es/Tooltip';
 
 
+import { createSubscribedElement } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
+
 import UserPicture from './UserPicture';
 import UserPicture from './UserPicture';
 
 
-export default class UserPictureList extends React.Component {
+class UserPictureList extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
@@ -15,7 +18,7 @@ export default class UserPictureList extends React.Component {
 
 
     const users = this.props.users.concat(
     const users = this.props.users.concat(
       // FIXME: user data cache
       // FIXME: user data cache
-      this.props.crowi.findUserByIds(userIds),
+      this.props.appContainer.findUserByIds(userIds),
     );
     );
 
 
     this.state = {
     this.state = {
@@ -47,8 +50,16 @@ export default class UserPictureList extends React.Component {
 
 
 }
 }
 
 
+/**
+ * Wrapper component for using unstated
+ */
+const UserPictureListWrapper = (props) => {
+  return createSubscribedElement(UserPictureList, props, [AppContainer]);
+};
+
 UserPictureList.propTypes = {
 UserPictureList.propTypes = {
-  crowi: PropTypes.object.isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   userIds: PropTypes.arrayOf(PropTypes.string),
   userIds: PropTypes.arrayOf(PropTypes.string),
   users: PropTypes.arrayOf(PropTypes.object),
   users: PropTypes.arrayOf(PropTypes.object),
 };
 };
@@ -57,3 +68,5 @@ UserPictureList.defaultProps = {
   userIds: [],
   userIds: [],
   users: [],
   users: [],
 };
 };
+
+export default UserPictureListWrapper;

+ 1 - 1
src/client/js/installer.js → src/client/js/installer.jsx

@@ -2,7 +2,7 @@ import React from 'react';
 import ReactDOM from 'react-dom';
 import ReactDOM from 'react-dom';
 import { I18nextProvider } from 'react-i18next';
 import { I18nextProvider } from 'react-i18next';
 
 
-import i18nFactory from './i18n';
+import i18nFactory from './util/i18n';
 
 
 import InstallerForm from './components/InstallerForm';
 import InstallerForm from './components/InstallerForm';
 
 

+ 35 - 37
src/client/js/legacy/crowi.js

@@ -4,6 +4,8 @@
 import React from 'react';
 import React from 'react';
 import ReactDOM from 'react-dom';
 import ReactDOM from 'react-dom';
 
 
+import { Provider } from 'unstated';
+
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
 import { pathUtils } from 'growi-commons';
 import { pathUtils } from 'growi-commons';
@@ -55,14 +57,15 @@ Crowi.setCaretLineAndFocusToEditor = function() {
     return;
     return;
   }
   }
 
 
-  const crowi = window.crowi;
+  const { appContainer } = window;
+  const editorContainer = appContainer.getContainer('EditorContainer');
   const line = pageEditorDom.getAttribute('data-caret-line') || 0;
   const line = pageEditorDom.getAttribute('data-caret-line') || 0;
-  crowi.setCaretLine(+line);
+  editorContainer.setCaretLine(+line);
   // reset data-caret-line attribute
   // reset data-caret-line attribute
   pageEditorDom.removeAttribute('data-caret-line');
   pageEditorDom.removeAttribute('data-caret-line');
 
 
   // focus
   // focus
-  crowi.focusToEditor();
+  editorContainer.focusToEditor();
 };
 };
 
 
 // original: middleware.swigFilter
 // original: middleware.swigFilter
@@ -251,26 +254,10 @@ Crowi.highlightSelectedSection = function(hash) {
   }
   }
 };
 };
 
 
-/**
- * Return editor mode string
- * @return 'builtin' or 'hackmd' or null (not editing)
- */
-Crowi.getCurrentEditorMode = function() {
-  const isEditing = $('body').hasClass('on-edit');
-  if (!isEditing) {
-    return null;
-  }
-
-  if ($('body').hasClass('builtin-editor')) {
-    return 'builtin';
-  }
-
-  return 'hackmd';
-};
-
 $(() => {
 $(() => {
-  const crowi = window.crowi;
-  const config = JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}');
+  const appContainer = window.appContainer;
+  const websocketContainer = appContainer.getContainer('WebsocketContainer');
+  const config = appContainer.getConfig();
 
 
   const pageId = $('#content-main').data('page-id');
   const pageId = $('#content-main').data('page-id');
   // const revisionId = $('#content-main').data('page-revision-id');
   // const revisionId = $('#content-main').data('page-revision-id');
@@ -357,7 +344,7 @@ $(() => {
     $(this).serializeArray().forEach((obj) => {
     $(this).serializeArray().forEach((obj) => {
       nameValueMap[obj.name] = obj.value; // nameValueMap.new_path is renamed page path
       nameValueMap[obj.name] = obj.value; // nameValueMap.new_path is renamed page path
     });
     });
-    nameValueMap.socketClientId = crowi.getSocketClientId();
+    nameValueMap.socketClientId = websocketContainer.getSocketClientId();
 
 
     $.ajax({
     $.ajax({
       type: 'POST',
       type: 'POST',
@@ -395,7 +382,7 @@ $(() => {
     $(this).serializeArray().forEach((obj) => {
     $(this).serializeArray().forEach((obj) => {
       nameValueMap[obj.name] = obj.value; // nameValueMap.new_path is duplicated page path
       nameValueMap[obj.name] = obj.value; // nameValueMap.new_path is duplicated page path
     });
     });
-    nameValueMap.socketClientId = crowi.getSocketClientId();
+    nameValueMap.socketClientId = websocketContainer.getSocketClientId();
 
 
     $.ajax({
     $.ajax({
       type: 'POST',
       type: 'POST',
@@ -431,7 +418,7 @@ $(() => {
     $('#delete-page-form').serializeArray().forEach((obj) => {
     $('#delete-page-form').serializeArray().forEach((obj) => {
       nameValueMap[obj.name] = obj.value;
       nameValueMap[obj.name] = obj.value;
     });
     });
-    nameValueMap.socketClientId = crowi.getSocketClientId();
+    nameValueMap.socketClientId = websocketContainer.getSocketClientId();
 
 
     $.ajax({
     $.ajax({
       type: 'POST',
       type: 'POST',
@@ -547,9 +534,7 @@ $(() => {
     const isShown = $('#view-timeline').data('shown');
     const isShown = $('#view-timeline').data('shown');
 
 
     if (growiRendererForTimeline == null) {
     if (growiRendererForTimeline == null) {
-      const crowi = window.crowi;
-      const crowiRenderer = window.crowiRenderer;
-      growiRendererForTimeline = new GrowiRenderer(crowi, crowiRenderer, { mode: 'timeline' });
+      growiRendererForTimeline = GrowiRenderer.generate('timeline');
     }
     }
 
 
     if (isShown === 0) {
     if (isShown === 0) {
@@ -564,14 +549,15 @@ $(() => {
         const revisionId = timelineElm.getAttribute('data-revision');
         const revisionId = timelineElm.getAttribute('data-revision');
 
 
         ReactDOM.render(
         ReactDOM.render(
-          <RevisionLoader
-            lazy
-            crowi={crowi}
-            crowiRenderer={growiRendererForTimeline}
-            pageId={pageId}
-            pagePath={pagePath}
-            revisionId={revisionId}
-          />,
+          <Provider inject={[appContainer]}>
+            <RevisionLoader
+              lazy
+              growiRenderer={growiRendererForTimeline}
+              pageId={pageId}
+              pagePath={pagePath}
+              revisionId={revisionId}
+            />
+          </Provider>,
           revisionBodyElem,
           revisionBodyElem,
         );
         );
       });
       });
@@ -587,7 +573,8 @@ $(() => {
       const templateId = $(this).data('template');
       const templateId = $(this).data('template');
       const template = $(`#${templateId}`).html();
       const template = $(`#${templateId}`).html();
 
 
-      crowi.saveDraft(path, template);
+      const pageContainer = appContainer.getContainer('PageContainer');
+      pageContainer.saveDraft(path, template);
       top.location.href = `${path}#edit`;
       top.location.href = `${path}#edit`;
     });
     });
 
 
@@ -625,7 +612,11 @@ $(() => {
   } // end if pageId
   } // end if pageId
 
 
   // tab changing handling
   // tab changing handling
+  $('a[href="#revision-body"]').on('show.bs.tab', () => {
+    appContainer.setState({ editorMode: null });
+  });
   $('a[href="#edit"]').on('show.bs.tab', () => {
   $('a[href="#edit"]').on('show.bs.tab', () => {
+    appContainer.setState({ editorMode: 'builtin' });
     $('body').addClass('on-edit');
     $('body').addClass('on-edit');
     $('body').addClass('builtin-editor');
     $('body').addClass('builtin-editor');
   });
   });
@@ -634,6 +625,7 @@ $(() => {
     $('body').removeClass('builtin-editor');
     $('body').removeClass('builtin-editor');
   });
   });
   $('a[href="#hackmd"]').on('show.bs.tab', () => {
   $('a[href="#hackmd"]').on('show.bs.tab', () => {
+    appContainer.setState({ editorMode: 'hackmd' });
     $('body').addClass('on-edit');
     $('body').addClass('on-edit');
     $('body').addClass('hackmd');
     $('body').addClass('hackmd');
   });
   });
@@ -689,9 +681,13 @@ $(() => {
 });
 });
 
 
 window.addEventListener('load', (e) => {
 window.addEventListener('load', (e) => {
+  const { appContainer } = window;
+
   // hash on page
   // hash on page
   if (location.hash) {
   if (location.hash) {
     if ((location.hash === '#edit' || location.hash === '#edit-form') && $('.tab-pane#edit').length > 0) {
     if ((location.hash === '#edit' || location.hash === '#edit-form') && $('.tab-pane#edit').length > 0) {
+      appContainer.setState({ editorMode: 'builtin' });
+
       $('a[data-toggle="tab"][href="#edit"]').tab('show');
       $('a[data-toggle="tab"][href="#edit"]').tab('show');
       $('body').addClass('on-edit');
       $('body').addClass('on-edit');
       $('body').addClass('builtin-editor');
       $('body').addClass('builtin-editor');
@@ -700,6 +696,8 @@ window.addEventListener('load', (e) => {
       Crowi.setCaretLineAndFocusToEditor();
       Crowi.setCaretLineAndFocusToEditor();
     }
     }
     else if (location.hash === '#hackmd' && $('.tab-pane#hackmd').length > 0) {
     else if (location.hash === '#hackmd' && $('.tab-pane#hackmd').length > 0) {
+      appContainer.setState({ editorMode: 'hackmd' });
+
       $('a[data-toggle="tab"][href="#hackmd"]').tab('show');
       $('a[data-toggle="tab"][href="#hackmd"]').tab('show');
       $('body').addClass('on-edit');
       $('body').addClass('on-edit');
       $('body').addClass('hackmd');
       $('body').addClass('hackmd');

+ 6 - 6
src/client/js/plugin.js

@@ -2,17 +2,17 @@ import loggerFactory from '@alias/logger';
 
 
 const logger = loggerFactory('growi:plugin');
 const logger = loggerFactory('growi:plugin');
 
 
-export default class CrowiPlugin {
+export default class GrowiPlugin {
 
 
   /**
   /**
    * process plugin entry
    * process plugin entry
    *
    *
-   * @param {Crowi} crowi Crowi context class
-   * @param {CrowiRenderer} crowiRenderer CrowiRenderer
+   * @param {AppContainer} appContainer
+   * @param {GrowiRenderer} originRenderer The origin instance of GrowiRenderer
    *
    *
    * @memberof CrowiPlugin
    * @memberof CrowiPlugin
    */
    */
-  installAll(crowi, crowiRenderer) {
+  installAll(appContainer, originRenderer) {
     // import plugin definitions
     // import plugin definitions
     let definitions = [];
     let definitions = [];
     try {
     try {
@@ -34,7 +34,7 @@ export default class CrowiPlugin {
         // v2 or above
         // v2 or above
         default:
         default:
           definition.entries.forEach((entry) => {
           definition.entries.forEach((entry) => {
-            entry(crowi, crowiRenderer);
+            entry(appContainer, originRenderer);
           });
           });
       }
       }
     });
     });
@@ -43,4 +43,4 @@ export default class CrowiPlugin {
 
 
 }
 }
 
 
-window.crowiPlugin = new CrowiPlugin(); // FIXME
+window.growiPlugin = new GrowiPlugin();

+ 341 - 0
src/client/js/services/AppContainer.js

@@ -0,0 +1,341 @@
+import { Container } from 'unstated';
+
+import axios from 'axios';
+
+import InterceptorManager from '@commons/service/interceptor-manager';
+
+import emojiStrategy from '../util/emojione/emoji_strategy_shrinked.json';
+import GrowiRenderer from '../util/GrowiRenderer';
+
+import {
+  DetachCodeBlockInterceptor,
+  RestoreCodeBlockInterceptor,
+} from '../util/interceptor/detach-code-blocks';
+
+import i18nFactory from '../util/i18n';
+
+/**
+ * Service container related to options for Application
+ * @extends {Container} unstated Container
+ */
+export default class AppContainer extends Container {
+
+  constructor() {
+    super();
+
+    this.state = {
+      editorMode: null,
+    };
+
+    const body = document.querySelector('body');
+
+    this.me = body.dataset.currentUsername;
+    this.isAdmin = body.dataset.isAdmin === 'true';
+    this.csrfToken = body.dataset.csrftoken;
+    this.isPluginEnabled = body.dataset.pluginEnabled === 'true';
+    this.isLoggedin = document.querySelector('.main-container.nologin') == null;
+
+    this.config = JSON.parse(document.getElementById('crowi-context-hydrate').textContent || '{}');
+
+    const userAgent = window.navigator.userAgent.toLowerCase();
+    this.isMobile = /iphone|ipad|android/.test(userAgent);
+
+    this.isDocSaved = true;
+
+    this.originRenderer = new GrowiRenderer(this);
+
+    this.interceptorManager = new InterceptorManager();
+    this.interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(this), 10); // process as soon as possible
+    this.interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(this), 900); // process as late as possible
+
+    const userlang = body.dataset.userlang;
+    this.i18n = i18nFactory(userlang);
+
+    this.users = [];
+    this.userByName = {};
+    this.userById = {};
+    this.recoverData();
+
+    if (this.isLoggedin) {
+      this.fetchUsers();
+    }
+
+    this.containerInstances = {};
+    this.componentInstances = {};
+    this.rendererInstances = {};
+
+    this.fetchUsers = this.fetchUsers.bind(this);
+    this.apiGet = this.apiGet.bind(this);
+    this.apiPost = this.apiPost.bind(this);
+    this.apiRequest = this.apiRequest.bind(this);
+  }
+
+  initPlugins() {
+    if (this.isPluginEnabled) {
+      const growiPlugin = window.growiPlugin;
+      growiPlugin.installAll(this, this.originRenderer);
+    }
+  }
+
+  injectToWindow() {
+    window.appContainer = this;
+
+    const originRenderer = this.getOriginRenderer();
+    window.growiRenderer = originRenderer;
+
+    // backward compatibility
+    window.crowi = this;
+    window.crowiRenderer = originRenderer;
+    window.crowiPlugin = window.growiPlugin;
+  }
+
+  /**
+   * @return {Object} window.Crowi (js/legacy/crowi.js)
+   */
+  getCrowiForJquery() {
+    return window.Crowi;
+  }
+
+  getConfig() {
+    return this.config;
+  }
+
+  /**
+   * Register unstated container instance
+   * @param {object} instance unstated container instance
+   */
+  registerContainer(instance) {
+    if (instance == null) {
+      throw new Error('The specified instance must not be null');
+    }
+
+    const className = instance.constructor.name;
+
+    if (this.containerInstances[className] != null) {
+      throw new Error('The specified instance couldn\'t register because the same type object has already been registered');
+    }
+
+    this.containerInstances[className] = instance;
+  }
+
+  /**
+   * Get registered unstated container instance
+   * !! THIS METHOD SHOULD ONLY BE USED FROM unstated CONTAINERS !!
+   * !! From component instances, inject containers with `import { Subscribe } from 'unstated'` !!
+   *
+   * @param {string} className
+   */
+  getContainer(className) {
+    return this.containerInstances[className];
+  }
+
+  /**
+   * Register React component instance
+   * @param {object} instance React component instance
+   */
+  registerComponentInstance(instance) {
+    if (instance == null) {
+      throw new Error('The specified instance must not be null');
+    }
+
+    const className = instance.constructor.name;
+
+    if (this.componentInstances[className] != null) {
+      throw new Error('The specified instance couldn\'t register because the same type object has already been registered');
+    }
+
+    this.componentInstances[className] = instance;
+  }
+
+  /**
+   * Get registered React component instance
+   * @param {string} className
+   */
+  getComponentInstance(className) {
+    return this.componentInstances[className];
+  }
+
+  getOriginRenderer() {
+    return this.originRenderer;
+  }
+
+  /**
+   * factory method
+   */
+  getRenderer(mode) {
+    if (this.rendererInstances[mode] != null) {
+      return this.rendererInstances[mode];
+    }
+
+    const renderer = new GrowiRenderer(this, this.originRenderer);
+    // setup
+    renderer.initMarkdownItConfigurers(mode);
+    renderer.setup(mode);
+    // register
+    this.rendererInstances[mode] = renderer;
+
+    return renderer;
+  }
+
+  setIsDocSaved(isSaved) {
+    this.isDocSaved = isSaved;
+  }
+
+  getIsDocSaved() {
+    return this.isDocSaved;
+  }
+
+  getEmojiStrategy() {
+    return emojiStrategy;
+  }
+
+  recoverData() {
+    const keys = [
+      'userByName',
+      'userById',
+      'users',
+    ];
+
+    keys.forEach((key) => {
+      const keyContent = window.localStorage[key];
+      if (keyContent) {
+        try {
+          this[key] = JSON.parse(keyContent);
+        }
+        catch (e) {
+          window.localStorage.removeItem(key);
+        }
+      }
+    });
+  }
+
+  fetchUsers() {
+    const interval = 1000 * 60 * 15; // 15min
+    const currentTime = new Date();
+    if (window.localStorage.lastFetched && interval > currentTime - new Date(window.localStorage.lastFetched)) {
+      return;
+    }
+
+    this.apiGet('/users.list', {})
+      .then((data) => {
+        this.users = data.users;
+        window.localStorage.users = JSON.stringify(data.users);
+
+        const userByName = {};
+        const userById = {};
+        for (let i = 0; i < data.users.length; i++) {
+          const user = data.users[i];
+          userByName[user.username] = user;
+          userById[user._id] = user;
+        }
+        this.userByName = userByName;
+        window.localStorage.userByName = JSON.stringify(userByName);
+
+        this.userById = userById;
+        window.localStorage.userById = JSON.stringify(userById);
+
+        window.localStorage.lastFetched = new Date();
+      })
+      .catch((err) => {
+        window.localStorage.removeItem('lastFetched');
+      // ignore errors
+      });
+  }
+
+  findUserById(userId) {
+    if (this.userById && this.userById[userId]) {
+      return this.userById[userId];
+    }
+
+    return null;
+  }
+
+  findUserByIds(userIds) {
+    const users = [];
+    for (const userId of userIds) {
+      const user = this.findUserById(userId);
+      if (user) {
+        users.push(user);
+      }
+    }
+
+    return users;
+  }
+
+  findUser(username) {
+    if (this.userByName && this.userByName[username]) {
+      return this.userByName[username];
+    }
+
+    return null;
+  }
+
+  createPage(pagePath, markdown, additionalParams = {}) {
+    const params = Object.assign(additionalParams, {
+      path: pagePath,
+      body: markdown,
+    });
+    return this.apiPost('/pages.create', params)
+      .then((res) => {
+        if (!res.ok) {
+          throw new Error(res.error);
+        }
+        return { page: res.page, tags: res.tags };
+      });
+  }
+
+  updatePage(pageId, revisionId, markdown, additionalParams = {}) {
+    const params = Object.assign(additionalParams, {
+      page_id: pageId,
+      revision_id: revisionId,
+      body: markdown,
+    });
+    return this.apiPost('/pages.update', params)
+      .then((res) => {
+        if (!res.ok) {
+          throw new Error(res.error);
+        }
+        return { page: res.page, tags: res.tags };
+      });
+  }
+
+  launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
+    let targetComponent;
+    switch (componentKind) {
+      case 'page':
+        targetComponent = this.getComponentInstance('Page');
+        break;
+    }
+    targetComponent.launchHandsontableModal(beginLineNumber, endLineNumber);
+  }
+
+  apiGet(path, params) {
+    return this.apiRequest('get', path, { params });
+  }
+
+  apiPost(path, params) {
+    if (!params._csrf) {
+      params._csrf = this.csrfToken;
+    }
+
+    return this.apiRequest('post', path, params);
+  }
+
+  apiRequest(method, path, params) {
+    return new Promise((resolve, reject) => {
+      axios[method](`/_api${path}`, params)
+        .then((res) => {
+          if (res.data.ok) {
+            resolve(res.data);
+          }
+          else {
+            reject(new Error(res.data.error));
+          }
+        })
+        .catch((res) => {
+          reject(res);
+        });
+    });
+  }
+
+}

+ 34 - 19
src/client/js/components/PageComment/CommentContainer.jsx → src/client/js/services/CommentContainer.js

@@ -1,5 +1,9 @@
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
+import loggerFactory from '@alias/logger';
+
+const logger = loggerFactory('growi:services:CommentContainer');
+
 /**
 /**
  *
  *
  * @author Yuki Takei <yuki@weseek.co.jp>
  * @author Yuki Takei <yuki@weseek.co.jp>
@@ -8,25 +12,32 @@ import { Container } from 'unstated';
  */
  */
 export default class CommentContainer extends Container {
 export default class CommentContainer extends Container {
 
 
-  constructor(crowi, pageId, pagePath, revisionId) {
+  constructor(appContainer) {
     super();
     super();
 
 
-    this.crowi = crowi;
-    this.pageId = pageId;
-    this.pagePath = pagePath;
-    this.revisionId = revisionId;
+    this.appContainer = appContainer;
+    this.appContainer.registerContainer(this);
+
+    const mainContent = document.querySelector('#content-main');
+
+    if (mainContent == null) {
+      logger.debug('#content-main element is not exists');
+      return;
+    }
 
 
     this.state = {
     this.state = {
       comments: [],
       comments: [],
+
+      // settings shared among all of CommentEditor
+      isSlackEnabled: false,
+      slackChannels: mainContent.getAttribute('data-slack-channels') || '',
     };
     };
 
 
     this.retrieveComments = this.retrieveComments.bind(this);
     this.retrieveComments = this.retrieveComments.bind(this);
   }
   }
 
 
-  init() {
-    if (!this.props.pageId) {
-      return;
-    }
+  getPageContainer() {
+    return this.appContainer.getContainer('PageContainer');
   }
   }
 
 
   findAndSplice(comment) {
   findAndSplice(comment) {
@@ -45,8 +56,10 @@ export default class CommentContainer extends Container {
    * Load data of comments and store them in state
    * Load data of comments and store them in state
    */
    */
   retrieveComments() {
   retrieveComments() {
+    const { pageId } = this.getPageContainer().state;
+
     // get data (desc order array)
     // get data (desc order array)
-    return this.crowi.apiGet('/comments.get', { page_id: this.pageId })
+    return this.appContainer.apiGet('/comments.get', { page_id: pageId })
       .then((res) => {
       .then((res) => {
         if (res.ok) {
         if (res.ok) {
           this.setState({ comments: res.comments });
           this.setState({ comments: res.comments });
@@ -58,12 +71,13 @@ export default class CommentContainer extends Container {
    * Load data of comments and rerender <PageComments />
    * Load data of comments and rerender <PageComments />
    */
    */
   postComment(comment, isMarkdown, replyTo, isSlackEnabled, slackChannels) {
   postComment(comment, isMarkdown, replyTo, isSlackEnabled, slackChannels) {
-    return this.crowi.apiPost('/comments.add', {
+    const { pageId, revisionId } = this.getPageContainer().state;
+
+    return this.appContainer.apiPost('/comments.add', {
       commentForm: {
       commentForm: {
         comment,
         comment,
-        _csrf: this.crowi.csrfToken,
-        page_id: this.pageId,
-        revision_id: this.revisionId,
+        page_id: pageId,
+        revision_id: revisionId,
         is_markdown: isMarkdown,
         is_markdown: isMarkdown,
         replyTo,
         replyTo,
       },
       },
@@ -80,7 +94,7 @@ export default class CommentContainer extends Container {
   }
   }
 
 
   deleteComment(comment) {
   deleteComment(comment) {
-    return this.crowi.apiPost('/comments.remove', { comment_id: comment._id })
+    return this.appContainer.apiPost('/comments.remove', { comment_id: comment._id })
       .then((res) => {
       .then((res) => {
         if (res.ok) {
         if (res.ok) {
           this.findAndSplice(comment);
           this.findAndSplice(comment);
@@ -89,14 +103,15 @@ export default class CommentContainer extends Container {
   }
   }
 
 
   uploadAttachment(file) {
   uploadAttachment(file) {
+    const { pageId, pagePath } = this.getPageContainer().state;
+
     const endpoint = '/attachments.add';
     const endpoint = '/attachments.add';
     const formData = new FormData();
     const formData = new FormData();
-    formData.append('_csrf', this.crowi.csrfToken);
     formData.append('file', file);
     formData.append('file', file);
-    formData.append('path', this.pagePath);
-    formData.append('page_id', this.pageId);
+    formData.append('path', pagePath);
+    formData.append('page_id', pageId);
 
 
-    return this.crowi.apiPost(endpoint, formData);
+    return this.appContainer.apiPost(endpoint, formData);
   }
   }
 
 
 }
 }

+ 115 - 0
src/client/js/services/EditorContainer.js

@@ -0,0 +1,115 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+const logger = loggerFactory('growi:services:EditorContainer');
+
+/**
+ * Service container related to options for Editor/Preview
+ * @extends {Container} unstated Container
+ */
+export default class EditorContainer extends Container {
+
+  constructor(appContainer, defaultEditorOptions, defaultPreviewOptions) {
+    super();
+
+    this.appContainer = appContainer;
+    this.appContainer.registerContainer(this);
+
+    const mainContent = document.querySelector('#content-main');
+
+    if (mainContent == null) {
+      logger.debug('#content-main element is not exists');
+      return;
+    }
+
+    this.state = {
+      tags: [],
+
+      isSlackEnabled: false,
+      slackChannels: mainContent.getAttribute('data-slack-channels') || '',
+
+      grant: 1, // default: public
+      grantGroupId: null,
+      grantGroupName: null,
+
+      editorOptions: {},
+      previewOptions: {},
+    };
+
+    this.initStateGrant();
+
+    this.initEditorOptions('editorOptions', 'editorOptions', defaultEditorOptions);
+    this.initEditorOptions('previewOptions', 'previewOptions', defaultPreviewOptions);
+  }
+
+  /**
+   * initialize state for page permission
+   */
+  initStateGrant() {
+    const elem = document.getElementById('save-page-controls');
+
+    if (elem) {
+      this.state.grant = +elem.dataset.grant;
+
+      const grantGroupId = elem.dataset.grantGroup;
+      if (grantGroupId != null && grantGroupId.length > 0) {
+        this.state.grantGroupId = grantGroupId;
+        this.state.grantGroupName = elem.dataset.grantGroupName;
+      }
+    }
+  }
+
+  initEditorOptions(stateKey, localStorageKey, defaultOptions) {
+    // load from localStorage
+    const optsStr = window.localStorage[localStorageKey];
+
+    let loadedOpts = {};
+    // JSON.parseparse
+    if (optsStr != null) {
+      try {
+        loadedOpts = JSON.parse(optsStr);
+      }
+      catch (e) {
+        this.localStorage.removeItem(localStorageKey);
+      }
+    }
+
+    // set to state obj
+    this.state[stateKey] = Object.assign(defaultOptions, loadedOpts);
+  }
+
+  saveOptsToLocalStorage() {
+    window.localStorage.setItem('editorOptions', JSON.stringify(this.state.editorOptions));
+    window.localStorage.setItem('previewOptions', JSON.stringify(this.state.previewOptions));
+  }
+
+  setCaretLine(line) {
+    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
+    if (pageEditor != null) {
+      pageEditor.setCaretLine(line);
+    }
+  }
+
+  focusToEditor() {
+    const pageEditor = this.appContainer.getComponentInstance('PageEditor');
+    if (pageEditor != null) {
+      pageEditor.focusToEditor();
+    }
+  }
+
+  getCurrentOptionsToSave() {
+    const opt = {
+      isSlackEnabled: this.state.isSlackEnabled,
+      slackChannels: this.state.slackChannels,
+      grant: this.state.grant,
+    };
+
+    if (this.state.grantGroupId != null) {
+      opt.grantUserGroupId = this.state.grantGroupId;
+    }
+
+    return opt;
+  }
+
+}

+ 221 - 0
src/client/js/services/PageContainer.js

@@ -0,0 +1,221 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+import * as entities from 'entities';
+
+const logger = loggerFactory('growi:services:PageContainer');
+
+/**
+ * Service container related to Page
+ * @extends {Container} unstated Container
+ */
+export default class PageContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+    this.appContainer.registerContainer(this);
+
+    this.state = {};
+
+    const mainContent = document.querySelector('#content-main');
+    if (mainContent == null) {
+      logger.debug('#content-main element is not exists');
+      return;
+    }
+
+    const revisionId = mainContent.getAttribute('data-page-revision-id');
+
+    this.state = {
+      // local page data
+      markdown: null, // will be initialized after initStateMarkdown()
+      pageId: mainContent.getAttribute('data-page-id'),
+      revisionId,
+      revisionCreatedAt: +mainContent.getAttribute('data-page-revision-created'),
+      path: mainContent.getAttribute('data-path'),
+      isLiked: false,
+      seenUserIds: [],
+      likerUserIds: [],
+
+      tags: [],
+      templateTagData: mainContent.getAttribute('data-template-tags') || '',
+
+      // latest(on remote) information
+      remoteRevisionId: revisionId,
+      revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced'),
+      lastUpdateUsername: undefined,
+      pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd'),
+      hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
+      isHackmdDraftUpdatingInRealtime: false,
+    };
+
+    this.initStateMarkdown();
+    this.initStateOthers();
+    this.initDrafts();
+
+    this.addWebSocketEventHandlers = this.addWebSocketEventHandlers.bind(this);
+    this.addWebSocketEventHandlers();
+  }
+
+  /**
+   * initialize state for markdown data
+   */
+  initStateMarkdown() {
+    let pageContent = '';
+
+    const rawText = document.getElementById('raw-text-original');
+    if (rawText) {
+      pageContent = rawText.innerHTML;
+    }
+    const markdown = entities.decodeHTML(pageContent);
+
+    this.state.markdown = markdown;
+  }
+
+  initStateOthers() {
+    const likeButtonElem = document.getElementById('like-button');
+    if (likeButtonElem != null) {
+      this.state.isLiked = likeButtonElem.dataset.liked === 'true';
+    }
+
+    const seenUserListElem = document.getElementById('seen-user-list');
+    if (seenUserListElem != null) {
+      const userIdsStr = seenUserListElem.dataset.userIds;
+      this.state.seenUserIds = userIdsStr.split(',');
+    }
+
+
+    const likerListElem = document.getElementById('liker-list');
+    if (likerListElem != null) {
+      const userIdsStr = likerListElem.dataset.userIds;
+      this.state.likerUserIds = userIdsStr.split(',');
+    }
+  }
+
+  /**
+   * initialize state for drafts
+   */
+  initDrafts() {
+    this.drafts = {};
+
+    // restore data from localStorage
+    const contents = window.localStorage.drafts;
+    if (contents != null) {
+      try {
+        this.drafts = JSON.parse(contents);
+      }
+      catch (e) {
+        window.localStorage.removeItem('drafts');
+      }
+    }
+
+    if (this.state.pageId == null) {
+      const draft = this.findDraft(this.state.path);
+      if (draft != null) {
+        this.state.markdown = draft;
+      }
+    }
+  }
+
+  setLatestRemotePageData(page, user) {
+    this.setState({
+      remoteRevisionId: page.revision._id,
+      revisionIdHackmdSynced: page.revisionHackmdSynced,
+      lastUpdateUsername: user.name,
+    });
+  }
+
+  clearDraft(path) {
+    delete this.drafts[path];
+    window.localStorage.setItem('drafts', JSON.stringify(this.drafts));
+  }
+
+  clearAllDrafts() {
+    window.localStorage.removeItem('drafts');
+  }
+
+  saveDraft(path, body) {
+    this.drafts[path] = body;
+    window.localStorage.setItem('drafts', JSON.stringify(this.drafts));
+  }
+
+  findDraft(path) {
+    if (this.drafts != null && this.drafts[path]) {
+      return this.drafts[path];
+    }
+
+    return null;
+  }
+
+  addWebSocketEventHandlers() {
+    const pageContainer = this;
+    const websocketContainer = this.appContainer.getContainer('WebsocketContainer');
+    const socket = websocketContainer.getWebSocket();
+
+    socket.on('page:create', (data) => {
+      // skip if triggered myself
+      if (data.socketClientId != null && data.socketClientId === websocketContainer.getCocketClientId()) {
+        return;
+      }
+
+      logger.debug({ obj: data }, `websocket on 'page:create'`); // eslint-disable-line quotes
+
+      // update PageStatusAlert
+      if (data.page.path === pageContainer.state.path) {
+        this.setLatestRemotePageData(data.page, data.user);
+      }
+    });
+
+    socket.on('page:update', (data) => {
+      // skip if triggered myself
+      if (data.socketClientId != null && data.socketClientId === websocketContainer.getCocketClientId()) {
+        return;
+      }
+
+      logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
+
+      if (data.page.path === pageContainer.state.path) {
+        // update PageStatusAlert
+        pageContainer.setLatestRemotePageData(data.page, data.user);
+        // update remote data
+        const page = data.page;
+        pageContainer.setState({
+          remoteRevisionId: page.revision._id,
+          revisionIdHackmdSynced: page.revisionHackmdSynced,
+          hasDraftOnHackmd: page.hasDraftOnHackmd,
+        });
+      }
+    });
+
+    socket.on('page:delete', (data) => {
+      // skip if triggered myself
+      if (data.socketClientId != null && data.socketClientId === websocketContainer.getCocketClientId()) {
+        return;
+      }
+
+      logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes
+
+      // update PageStatusAlert
+      if (data.page.path === pageContainer.state.path) {
+        pageContainer.setLatestRemotePageData(data.page, data.user);
+      }
+    });
+
+    socket.on('page:editingWithHackmd', (data) => {
+      // skip if triggered myself
+      if (data.socketClientId != null && data.socketClientId === websocketContainer.getCocketClientId()) {
+        return;
+      }
+
+      logger.debug({ obj: data }, `websocket on 'page:editingWithHackmd'`); // eslint-disable-line quotes
+
+      if (data.page.path === pageContainer.state.path) {
+        pageContainer.setState({ isHackmdDraftUpdatingInRealtime: true });
+      }
+    });
+
+  }
+
+}

+ 54 - 0
src/client/js/services/TagContainer.js

@@ -0,0 +1,54 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+const logger = loggerFactory('growi:services:TagContainer');
+
+/**
+ * Service container related to Tag
+ * @extends {Container} unstated Container
+ */
+export default class TagContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+    this.appContainer.registerContainer(this);
+
+    this.init();
+  }
+
+  /**
+   * retrieve tags data
+   * !! This method should be invoked after PageContainer and EditorContainer has been initialized !!
+   */
+  async init() {
+    const pageContainer = this.appContainer.getContainer('PageContainer');
+    const editorContainer = this.appContainer.getContainer('EditorContainer');
+
+    if (Object.keys(pageContainer.state).length === 0) {
+      logger.debug('There is no need to initialize TagContainer because this is not a Page');
+      return;
+    }
+
+    const { pageId, templateTagData } = pageContainer.state;
+
+    let tags;
+    // when the page exists
+    if (pageId != null) {
+      const res = await this.appContainer.apiGet('/pages.getPageTag', { pageId });
+      tags = res.tags;
+    }
+    // when the page not exist
+    else if (templateTagData != null) {
+      tags = templateTagData.split(',');
+    }
+
+    logger.debug('tags data has been initialized');
+
+    pageContainer.setState({ tags });
+    editorContainer.setState({ tags });
+  }
+
+}

+ 33 - 0
src/client/js/services/WebsocketContainer.js

@@ -0,0 +1,33 @@
+import { Container } from 'unstated';
+
+import io from 'socket.io-client';
+
+/**
+ * Service container related to options for WebSocket
+ * @extends {Container} unstated Container
+ */
+export default class WebsocketContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+    this.appContainer.registerContainer(this);
+
+    this.socket = io();
+    this.socketClientId = Math.floor(Math.random() * 100000);
+
+    this.state = {
+    };
+
+  }
+
+  getWebSocket() {
+    return this.socket;
+  }
+
+  getCocketClientId() {
+    return this.socketClientId;
+  }
+
+}

+ 0 - 297
src/client/js/util/Crowi.js

@@ -1,297 +0,0 @@
-/**
- * Crowi context class for client
- */
-
-import axios from 'axios';
-import io from 'socket.io-client';
-
-import InterceptorManager from '@commons/service/interceptor-manager';
-
-import emojiStrategy from './emojione/emoji_strategy_shrinked.json';
-
-import {
-  DetachCodeBlockInterceptor,
-  RestoreCodeBlockInterceptor,
-} from './interceptor/detach-code-blocks';
-
-export default class Crowi {
-
-  constructor(context, window) {
-    this.context = context;
-    this.config = {};
-
-    const userAgent = window.navigator.userAgent.toLowerCase();
-    this.isMobile = /iphone|ipad|android/.test(userAgent);
-
-    this.window = window;
-    this.location = window.location || {};
-    this.document = window.document || {};
-    this.localStorage = window.localStorage || {};
-    this.socketClientId = Math.floor(Math.random() * 100000);
-    this.page = undefined;
-    this.pageEditor = undefined;
-    this.isDocSaved = true;
-
-    this.fetchUsers = this.fetchUsers.bind(this);
-    this.apiGet = this.apiGet.bind(this);
-    this.apiPost = this.apiPost.bind(this);
-    this.apiRequest = this.apiRequest.bind(this);
-
-    this.interceptorManager = new InterceptorManager();
-    this.interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(this), 10); // process as soon as possible
-    this.interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(this), 900); // process as late as possible
-
-    // FIXME
-    this.me = context.me;
-    this.isAdmin = context.isAdmin;
-    this.csrfToken = context.csrfToken;
-
-    this.users = [];
-    this.userByName = {};
-    this.userById = {};
-    this.draft = {};
-    this.editorOptions = {};
-
-    this.recoverData();
-
-    this.socket = io();
-  }
-
-  /**
-   * @return {Object} window.Crowi (js/legacy/crowi.js)
-   */
-  getCrowiForJquery() {
-    return window.Crowi;
-  }
-
-  setConfig(config) {
-    this.config = config;
-  }
-
-  getConfig() {
-    return this.config;
-  }
-
-  setPage(page) {
-    this.page = page;
-  }
-
-  setPageEditor(pageEditor) {
-    this.pageEditor = pageEditor;
-  }
-
-  setIsDocSaved(isSaved) {
-    this.isDocSaved = isSaved;
-  }
-
-  getIsDocSaved() {
-    return this.isDocSaved;
-  }
-
-  getWebSocket() {
-    return this.socket;
-  }
-
-  getSocketClientId() {
-    return this.socketClientId;
-  }
-
-  getEmojiStrategy() {
-    return emojiStrategy;
-  }
-
-  recoverData() {
-    const keys = [
-      'userByName',
-      'userById',
-      'users',
-      'draft',
-      'editorOptions',
-      'previewOptions',
-    ];
-
-    keys.forEach((key) => {
-      const keyContent = this.localStorage[key];
-      if (keyContent) {
-        try {
-          this[key] = JSON.parse(keyContent);
-        }
-        catch (e) {
-          this.localStorage.removeItem(key);
-        }
-      }
-    });
-  }
-
-  fetchUsers() {
-    const interval = 1000 * 60 * 15; // 15min
-    const currentTime = new Date();
-    if (this.localStorage.lastFetched && interval > currentTime - new Date(this.localStorage.lastFetched)) {
-      return;
-    }
-
-    this.apiGet('/users.list', {})
-      .then((data) => {
-        this.users = data.users;
-        this.localStorage.users = JSON.stringify(data.users);
-
-        const userByName = {};
-        const userById = {};
-        for (let i = 0; i < data.users.length; i++) {
-          const user = data.users[i];
-          userByName[user.username] = user;
-          userById[user._id] = user;
-        }
-        this.userByName = userByName;
-        this.localStorage.userByName = JSON.stringify(userByName);
-
-        this.userById = userById;
-        this.localStorage.userById = JSON.stringify(userById);
-
-        this.localStorage.lastFetched = new Date();
-      })
-      .catch((err) => {
-        this.localStorage.removeItem('lastFetched');
-      // ignore errors
-      });
-  }
-
-  setCaretLine(line) {
-    if (this.pageEditor != null) {
-      this.pageEditor.setCaretLine(line);
-    }
-  }
-
-  focusToEditor() {
-    if (this.pageEditor != null) {
-      this.pageEditor.focusToEditor();
-    }
-  }
-
-  clearDraft(path) {
-    delete this.draft[path];
-    this.localStorage.setItem('draft', JSON.stringify(this.draft));
-  }
-
-  clearAllDrafts() {
-    this.localStorage.removeItem('draft');
-  }
-
-  saveDraft(path, body) {
-    this.draft[path] = body;
-    this.localStorage.setItem('draft', JSON.stringify(this.draft));
-  }
-
-  findDraft(path) {
-    if (this.draft && this.draft[path]) {
-      return this.draft[path];
-    }
-
-    return null;
-  }
-
-  saveEditorOptions(options) {
-    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];
-    }
-
-    return null;
-  }
-
-  findUserByIds(userIds) {
-    const users = [];
-    for (const userId of userIds) {
-      const user = this.findUserById(userId);
-      if (user) {
-        users.push(user);
-      }
-    }
-
-    return users;
-  }
-
-  findUser(username) {
-    if (this.userByName && this.userByName[username]) {
-      return this.userByName[username];
-    }
-
-    return null;
-  }
-
-  createPage(pagePath, markdown, additionalParams = {}) {
-    const params = Object.assign(additionalParams, {
-      path: pagePath,
-      body: markdown,
-    });
-    return this.apiPost('/pages.create', params)
-      .then((res) => {
-        if (!res.ok) {
-          throw new Error(res.error);
-        }
-        return res.page;
-      });
-  }
-
-  updatePage(pageId, revisionId, markdown, additionalParams = {}) {
-    const params = Object.assign(additionalParams, {
-      page_id: pageId,
-      revision_id: revisionId,
-      body: markdown,
-    });
-    return this.apiPost('/pages.update', params)
-      .then((res) => {
-        if (!res.ok) {
-          throw new Error(res.error);
-        }
-        return res.page;
-      });
-  }
-
-  launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
-    let targetComponent;
-    switch (componentKind) {
-      case 'page':
-        targetComponent = this.page;
-        break;
-    }
-    targetComponent.launchHandsontableModal(beginLineNumber, endLineNumber);
-  }
-
-  apiGet(path, params) {
-    return this.apiRequest('get', path, { params });
-  }
-
-  apiPost(path, params) {
-    if (!params._csrf) {
-      params._csrf = this.csrfToken;
-    }
-
-    return this.apiRequest('post', path, params);
-  }
-
-  apiRequest(method, path, params) {
-    return new Promise((resolve, reject) => {
-      axios[method](`/_api${path}`, params)
-        .then((res) => {
-          if (res.data.ok) {
-            resolve(res.data);
-          }
-          else {
-            reject(new Error(res.data.error));
-          }
-        })
-        .catch((res) => {
-          reject(res);
-        });
-    });
-  }
-
-}

+ 47 - 53
src/client/js/util/GrowiRenderer.js

@@ -20,38 +20,40 @@ import HeaderWithEditLinkConfigurer from './markdown-it/header-with-edit-link';
 
 
 const logger = require('@alias/logger')('growi:util:GrowiRenderer');
 const logger = require('@alias/logger')('growi:util:GrowiRenderer');
 
 
-
 export default class GrowiRenderer {
 export default class GrowiRenderer {
 
 
   /**
   /**
    *
    *
-   * @param {Crowi} crowi
-   * @param {GrowiRenderer} originRenderer may be customized by plugins
-   * @param {object} options
+   * @param {AppContainer} appContainer
+   * @param {GrowiRenderer} originRenderer
+   * @param {string} mode
    */
    */
-  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 || [
-      new CrowiTemplate(crowi),
-    ];
+  constructor(appContainer, originRenderer) {
+    this.appContainer = appContainer;
+
+    if (originRenderer != null) {
+      this.preProcessors = originRenderer.preProcessors;
+      this.postProcessors = originRenderer.postProcessors;
+    }
+    else {
+      this.preProcessors = [
+        new Linker(appContainer),
+        new CsvToTable(appContainer),
+        new XssFilter(appContainer),
+      ];
+      this.postProcessors = [
+        new CrowiTemplate(appContainer),
+      ];
+    }
 
 
     this.initMarkdownItConfigurers = this.initMarkdownItConfigurers.bind(this);
     this.initMarkdownItConfigurers = this.initMarkdownItConfigurers.bind(this);
     this.setup = this.setup.bind(this);
     this.setup = this.setup.bind(this);
     this.process = this.process.bind(this);
     this.process = this.process.bind(this);
     this.codeRenderer = this.codeRenderer.bind(this);
     this.codeRenderer = this.codeRenderer.bind(this);
+  }
+
+  initMarkdownItConfigurers(mode) {
+    const appContainer = this.appContainer;
 
 
     // init markdown-it
     // init markdown-it
     this.md = new MarkdownIt({
     this.md = new MarkdownIt({
@@ -59,52 +61,44 @@ export default class GrowiRenderer {
       linkify: true,
       linkify: true,
       highlight: this.codeRenderer,
       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.isMarkdownItConfigured = false;
 
 
     this.markdownItConfigurers = [
     this.markdownItConfigurers = [
-      new TaskListsConfigurer(crowi),
-      new HeaderConfigurer(crowi),
-      new EmojiConfigurer(crowi),
-      new MathJaxConfigurer(crowi),
-      new PlantUMLConfigurer(crowi),
-      new BlockdiagConfigurer(crowi),
+      new TaskListsConfigurer(appContainer),
+      new HeaderConfigurer(appContainer),
+      new EmojiConfigurer(appContainer),
+      new MathJaxConfigurer(appContainer),
+      new PlantUMLConfigurer(appContainer),
+      new BlockdiagConfigurer(appContainer),
     ];
     ];
 
 
     // add configurers according to mode
     // add configurers according to mode
-    const mode = options.mode;
     switch (mode) {
     switch (mode) {
-      case 'page':
+      case 'page': {
+        const renderToc = appContainer.getCrowiForJquery().renderTocContent;
+
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new FooternoteConfigurer(crowi),
-          new TocAndAnchorConfigurer(crowi, options.renderToc),
-          new HeaderLineNumberConfigurer(crowi),
-          new HeaderWithEditLinkConfigurer(crowi),
-          new TableWithHandsontableButtonConfigurer(crowi),
+          new FooternoteConfigurer(appContainer),
+          new TocAndAnchorConfigurer(appContainer, renderToc),
+          new HeaderLineNumberConfigurer(appContainer),
+          new HeaderWithEditLinkConfigurer(appContainer),
+          new TableWithHandsontableButtonConfigurer(appContainer),
         ]);
         ]);
         break;
         break;
+      }
       case 'editor':
       case 'editor':
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new FooternoteConfigurer(crowi),
-          new HeaderLineNumberConfigurer(crowi),
-          new TableConfigurer(crowi),
+          new FooternoteConfigurer(appContainer),
+          new HeaderLineNumberConfigurer(appContainer),
+          new TableConfigurer(appContainer),
         ]);
         ]);
         break;
         break;
       // case 'comment':
       // case 'comment':
       //   break;
       //   break;
       default:
       default:
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new TableConfigurer(crowi),
+          new TableConfigurer(appContainer),
         ]);
         ]);
         break;
         break;
     }
     }
@@ -113,11 +107,11 @@ export default class GrowiRenderer {
   /**
   /**
    * setup with crowi config
    * setup with crowi config
    */
    */
-  setup() {
-    const crowiConfig = this.crowi.config;
+  setup(mode) {
+    const crowiConfig = this.appContainer.config;
 
 
     let isEnabledLinebreaks;
     let isEnabledLinebreaks;
-    switch (this.options.mode) {
+    switch (mode) {
       case 'comment':
       case 'comment':
         isEnabledLinebreaks = crowiConfig.isEnabledLinebreaksInComments;
         isEnabledLinebreaks = crowiConfig.isEnabledLinebreaksInComments;
         break;
         break;
@@ -166,7 +160,7 @@ export default class GrowiRenderer {
   }
   }
 
 
   codeRenderer(code, langExt) {
   codeRenderer(code, langExt) {
-    const config = this.crowi.getConfig();
+    const config = this.appContainer.getConfig();
     const noborder = (!config.highlightJsStyleBorder) ? 'hljs-no-border' : '';
     const noborder = (!config.highlightJsStyleBorder) ? 'hljs-no-border' : '';
 
 
     let citeTag = '';
     let citeTag = '';

+ 5 - 2
src/client/js/util/PostProcessor/CrowiTemplate.js

@@ -3,6 +3,10 @@ import dateFnsFormat from 'date-fns/format';
 export default class CrowiTemplate {
 export default class CrowiTemplate {
 
 
   constructor(crowi) {
   constructor(crowi) {
+    this.crowi = crowi;
+
+    this.getUser = this.getUser.bind(this);
+
     this.templatePattern = {
     this.templatePattern = {
       year: this.getYear,
       year: this.getYear,
       month: this.getMonth,
       month: this.getMonth,
@@ -54,8 +58,7 @@ export default class CrowiTemplate {
   }
   }
 
 
   getUser() {
   getUser() {
-    // FIXME
-    const username = window.crowi.me || null;
+    const username = this.crowi.me || null;
 
 
     if (!username) {
     if (!username) {
       return '';
       return '';

+ 0 - 0
src/client/js/i18n.js → src/client/js/util/i18n.js


+ 8 - 12
src/client/js/util/reveal/plugins/growi-renderer.js

@@ -1,24 +1,21 @@
-import GrowiRenderer from '../../GrowiRenderer';
-
 /**
 /**
  * reveal.js growi-renderer plugin.
  * reveal.js growi-renderer plugin.
  */
  */
 (function(root, factory) {
 (function(root, factory) {
-  // parent window DOM (crowi.js) of presentation window.
-  const parentWindow = window.parent;
-
-  // create GrowiRenderer instance and setup.
-  const growiRenderer = new GrowiRenderer(parentWindow.crowi, parentWindow.crowiRenderer, { mode: 'editor' });
+  // get AppContainer instance from parent window
+  const appContainer = window.parent.appContainer;
 
 
-  const growiRendererPlugin = factory(growiRenderer);
+  const growiRendererPlugin = factory(appContainer);
   growiRendererPlugin.initialize();
   growiRendererPlugin.initialize();
-}(this, (growiRenderer) => {
+}(this, (appContainer) => {
   /* eslint-disable no-useless-escape */
   /* eslint-disable no-useless-escape */
   const DEFAULT_SLIDE_SEPARATOR = '^\r?\n---\r?\n$';
   const DEFAULT_SLIDE_SEPARATOR = '^\r?\n---\r?\n$';
   const DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR = '\\\.element\\\s*?(.+?)$';
   const DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR = '\\\.element\\\s*?(.+?)$';
   const DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR = '\\\.slide:\\\s*?(\\\S.+?)$';
   const DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR = '\\\.slide:\\\s*?(\\\S.+?)$';
   /* eslint-enable no-useless-escape */
   /* eslint-enable no-useless-escape */
 
 
+  const growiRenderer = appContainer.getRenderer('editor');
+
   let marked;
   let marked;
 
 
   /**
   /**
@@ -31,7 +28,7 @@ import GrowiRenderer from '../../GrowiRenderer';
       const section = sections[i];
       const section = sections[i];
       const markdown = marked.getMarkdownFromSlide(section);
       const markdown = marked.getMarkdownFromSlide(section);
       const context = { markdown };
       const context = { markdown };
-      const interceptorManager = growiRenderer.crowi.interceptorManager;
+      const interceptorManager = appContainer.interceptorManager;
       let dataSeparator = section.getAttribute('data-separator') || DEFAULT_SLIDE_SEPARATOR;
       let dataSeparator = section.getAttribute('data-separator') || DEFAULT_SLIDE_SEPARATOR;
       // replace string '\n' to LF code.
       // replace string '\n' to LF code.
       dataSeparator = dataSeparator.replace(/\\n/g, '\n');
       dataSeparator = dataSeparator.replace(/\\n/g, '\n');
@@ -54,7 +51,7 @@ import GrowiRenderer from '../../GrowiRenderer';
   function convertSlides() {
   function convertSlides() {
     const sections = document.querySelectorAll('[data-markdown]');
     const sections = document.querySelectorAll('[data-markdown]');
     let markdown;
     let markdown;
-    const interceptorManager = growiRenderer.crowi.interceptorManager;
+    const interceptorManager = appContainer.interceptorManager;
 
 
     for (let i = 0, len = sections.length; i < len; i++) {
     for (let i = 0, len = sections.length; i < len; i++) {
       const section = sections[i];
       const section = sections[i];
@@ -104,7 +101,6 @@ import GrowiRenderer from '../../GrowiRenderer';
   // API
   // API
   return {
   return {
     async initialize() {
     async initialize() {
-      growiRenderer.setup();
       marked = require('./markdown').default(growiRenderer.process);
       marked = require('./markdown').default(growiRenderer.process);
       divideSlides();
       divideSlides();
       marked.processSlides();
       marked.processSlides();

+ 30 - 0
src/server/models/page-tag-relation.js

@@ -53,6 +53,36 @@ class PageTagRelation {
     return { list, totalCount };
     return { list, totalCount };
   }
   }
 
 
+  static async listTagsByPage(pageId) {
+    return this.find({ relatedPage: pageId }).populate('relatedTag').select('-_id relatedTag');
+  }
+
+  static async listTagNamesByPage(pageId) {
+    const tags = await this.listTagsByPage(pageId);
+    return tags.map((tag) => { return tag.relatedTag.name });
+  }
+
+  static async updatePageTags(pageId, tags) {
+    const Tag = mongoose.model('Tag');
+
+    // get tags relate this page
+    const relatedTags = await this.listTagsByPage(pageId);
+
+    // unlink relations
+    const unlinkTagRelations = relatedTags.filter((tag) => { return !tags.includes(tag.relatedTag.name) });
+    await this.deleteMany({
+      relatedPage: pageId,
+      relatedTag: { $in: unlinkTagRelations.map((relation) => { return relation.relatedTag._id }) },
+    });
+
+    // create tag and relations
+    /* eslint-disable no-await-in-loop */
+    for (const tag of tags) {
+      const setTag = await Tag.findOrCreate(tag);
+      await this.createIfNotExist(pageId, setTag._id);
+    }
+  }
+
 }
 }
 
 
 module.exports = function() {
 module.exports = function() {

+ 3 - 37
src/server/models/page.js

@@ -310,30 +310,6 @@ module.exports = function(crowi) {
     return false;
     return false;
   };
   };
 
 
-  pageSchema.methods.updateTags = async function(newTags) {
-    const page = this;
-    const PageTagRelation = mongoose.model('PageTagRelation');
-    const Tag = mongoose.model('Tag');
-
-    // get tags relate this page
-    const relatedTags = await PageTagRelation.find({ relatedPage: page._id }).populate('relatedTag').select('-_id relatedTag');
-
-    // unlink relations
-    const unlinkTagRelations = relatedTags.filter((tag) => { return !newTags.includes(tag.relatedTag.name) });
-    await PageTagRelation.deleteMany({
-      relatedPage: page._id,
-      relatedTag: { $in: unlinkTagRelations.map((relation) => { return relation.relatedTag._id }) },
-    });
-
-    // create tag and relations
-    /* eslint-disable no-await-in-loop */
-    for (const tag of newTags) {
-      const setTag = await Tag.findOrCreate(tag);
-      await PageTagRelation.createIfNotExist(page._id, setTag._id);
-    }
-  };
-
-
   pageSchema.methods.isPortal = function() {
   pageSchema.methods.isPortal = function() {
     return isPortalPath(this.path);
     return isPortalPath(this.path);
   };
   };
@@ -370,8 +346,8 @@ module.exports = function(crowi) {
   };
   };
 
 
   pageSchema.methods.isLiked = function(userData) {
   pageSchema.methods.isLiked = function(userData) {
-    return this.liker.some((likedUser) => {
-      return likedUser === userData._id.toString();
+    return this.liker.some((likedUserId) => {
+      return likedUserId.toString() === userData._id.toString();
     });
     });
   };
   };
 
 
@@ -390,7 +366,7 @@ module.exports = function(crowi) {
         });
         });
       }
       }
       else {
       else {
-        debug('liker not updated');
+        this.logger.warn('liker not updated');
         return reject(self);
         return reject(self);
       }
       }
     }));
     }));
@@ -993,7 +969,6 @@ module.exports = function(crowi) {
     const redirectTo = options.redirectTo || null;
     const redirectTo = options.redirectTo || null;
     const grantUserGroupId = options.grantUserGroupId || null;
     const grantUserGroupId = options.grantUserGroupId || null;
     const socketClientId = options.socketClientId || null;
     const socketClientId = options.socketClientId || null;
-    const pageTags = options.pageTags || null;
 
 
     // sanitize path
     // sanitize path
     path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
     path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
@@ -1027,10 +1002,6 @@ module.exports = function(crowi) {
       .populate('revision')
       .populate('revision')
       .populate('creator');
       .populate('creator');
 
 
-    if (pageTags != null) {
-      await page.updateTags(pageTags);
-    }
-
     if (socketClientId != null) {
     if (socketClientId != null) {
       pageEvent.emit('create', savedPage, user, socketClientId);
       pageEvent.emit('create', savedPage, user, socketClientId);
     }
     }
@@ -1045,7 +1016,6 @@ module.exports = function(crowi) {
     const grantUserGroupId = options.grantUserGroupId || null;
     const grantUserGroupId = options.grantUserGroupId || null;
     const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
     const isSyncRevisionToHackmd = options.isSyncRevisionToHackmd;
     const socketClientId = options.socketClientId || null;
     const socketClientId = options.socketClientId || null;
-    const pageTags = options.pageTags || null;
 
 
     await validateAppliedScope(user, grant, grantUserGroupId);
     await validateAppliedScope(user, grant, grantUserGroupId);
     pageData.applyScope(user, grant, grantUserGroupId);
     pageData.applyScope(user, grant, grantUserGroupId);
@@ -1062,10 +1032,6 @@ module.exports = function(crowi) {
       savedPage = await this.syncRevisionToHackmd(savedPage);
       savedPage = await this.syncRevisionToHackmd(savedPage);
     }
     }
 
 
-    if (pageTags != null) {
-      await savedPage.updateTags(pageTags);
-    }
-
     if (socketClientId != null) {
     if (socketClientId != null) {
       pageEvent.emit('update', savedPage, user, socketClientId);
       pageEvent.emit('update', savedPage, user, socketClientId);
     }
     }

+ 16 - 5
src/server/routes/page.js

@@ -572,7 +572,13 @@ module.exports = function(crowi, app) {
     };
     };
     const createdPage = await Page.create(pagePath, body, req.user, options);
     const createdPage = await Page.create(pagePath, body, req.user, options);
 
 
-    const result = { page: serializeToObj(createdPage) };
+    let savedTags;
+    if (pageTags != null) {
+      await PageTagRelation.updatePageTags(createdPage.id, pageTags);
+      savedTags = await PageTagRelation.listTagNamesByPage(createdPage.id);
+    }
+
+    const result = { page: serializeToObj(createdPage), tags: savedTags };
     result.page.lastUpdateUser = User.filterToPublicFields(createdPage.lastUpdateUser);
     result.page.lastUpdateUser = User.filterToPublicFields(createdPage.lastUpdateUser);
     result.page.creator = User.filterToPublicFields(createdPage.creator);
     result.page.creator = User.filterToPublicFields(createdPage.creator);
     res.json(ApiResponse.success(result));
     res.json(ApiResponse.success(result));
@@ -639,7 +645,7 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'outdated'));
       return res.json(ApiResponse.error('Posted param "revisionId" is outdated.', 'outdated'));
     }
     }
 
 
-    const options = { isSyncRevisionToHackmd, socketClientId, pageTags };
+    const options = { isSyncRevisionToHackmd, socketClientId };
     if (grant != null) {
     if (grant != null) {
       options.grant = grant;
       options.grant = grant;
     }
     }
@@ -657,7 +663,13 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
 
 
-    const result = { page: serializeToObj(page) };
+    let savedTags;
+    if (pageTags != null) {
+      await PageTagRelation.updatePageTags(pageId, pageTags);
+      savedTags = await PageTagRelation.listTagNamesByPage(pageId);
+    }
+
+    const result = { page: serializeToObj(page), tags: savedTags };
     result.page.lastUpdateUser = User.filterToPublicFields(page.lastUpdateUser);
     result.page.lastUpdateUser = User.filterToPublicFields(page.lastUpdateUser);
     res.json(ApiResponse.success(result));
     res.json(ApiResponse.success(result));
 
 
@@ -758,8 +770,7 @@ module.exports = function(crowi, app) {
   api.getPageTag = async function(req, res) {
   api.getPageTag = async function(req, res) {
     const result = {};
     const result = {};
     try {
     try {
-      const tags = await PageTagRelation.find({ relatedPage: req.query.pageId }).populate('relatedTag').select('-_id relatedTag');
-      result.tags = tags.map((tag) => { return tag.relatedTag.name });
+      result.tags = await PageTagRelation.listTagNamesByPage(req.query.pageId);
     }
     }
     catch (err) {
     catch (err) {
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));

+ 6 - 2
src/server/routes/tag.js

@@ -35,20 +35,24 @@ module.exports = function(crowi, app) {
    */
    */
   api.update = async function(req, res) {
   api.update = async function(req, res) {
     const Page = crowi.model('Page');
     const Page = crowi.model('Page');
+    const PageTagRelation = crowi.model('PageTagRelation');
     const tagEvent = crowi.event('tag');
     const tagEvent = crowi.event('tag');
     const pageId = req.body.pageId;
     const pageId = req.body.pageId;
     const tags = req.body.tags;
     const tags = req.body.tags;
 
 
+    const result = {};
     try {
     try {
+      // TODO GC-1921 consider permission
       const page = await Page.findById(pageId);
       const page = await Page.findById(pageId);
-      await page.updateTags(tags);
+      await PageTagRelation.updatePageTags(pageId, tags);
+      result.tags = await PageTagRelation.listTagNamesByPage(pageId);
 
 
       tagEvent.emit('update', page, tags);
       tagEvent.emit('update', page, tags);
     }
     }
     catch (err) {
     catch (err) {
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
-    return res.json(ApiResponse.success());
+    return res.json(ApiResponse.success(result));
   };
   };
 
 
   /**
   /**