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

Merge branch 'master' into feat/108543-create-error-handler-for-external-account

Shun Miyazawa 3 лет назад
Родитель
Сommit
080547581d
100 измененных файлов с 1423 добавлено и 1725 удалено
  1. 2 4
      .github/workflows/ci-app-prod.yml
  2. 3 4
      .github/workflows/ci-app.yml
  3. 2 6
      .vscode/launch.json
  4. 0 0
      packages-obsolete/plugin-attachment-refs/.eslintignore
  5. 0 0
      packages-obsolete/plugin-attachment-refs/.gitignore
  6. 0 0
      packages-obsolete/plugin-attachment-refs/README.md
  7. 0 0
      packages-obsolete/plugin-attachment-refs/package.json
  8. 0 0
      packages-obsolete/plugin-attachment-refs/src/client-entry.js
  9. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/css/index.css
  10. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/components/AttachmentList.jsx
  11. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/components/ExtractedAttachments.jsx
  12. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/util/GalleryContext.js
  13. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js
  14. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPreRenderInterceptor.js
  15. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/util/RefsContext.js
  16. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/util/TagCacheManagerFactory.js
  17. 0 0
      packages-obsolete/plugin-attachment-refs/src/index.js
  18. 0 0
      packages-obsolete/plugin-attachment-refs/src/server-entry.js
  19. 0 0
      packages-obsolete/plugin-attachment-refs/src/server/routes/index.js
  20. 0 0
      packages-obsolete/plugin-attachment-refs/src/server/routes/refs.js
  21. 0 0
      packages-obsolete/plugin-attachment-refs/src/utils/logger/index.ts
  22. 0 0
      packages-obsolete/plugin-attachment-refs/tsconfig.base.json
  23. 0 0
      packages-obsolete/plugin-attachment-refs/tsconfig.build.cjs.json
  24. 0 0
      packages-obsolete/plugin-attachment-refs/tsconfig.build.esm.json
  25. 0 0
      packages-obsolete/plugin-attachment-refs/tsconfig.json
  26. 2 2
      packages/app/docker/Dockerfile
  27. 3 2
      packages/app/package.json
  28. 2 2
      packages/app/public/static/locales/en_US/admin.json
  29. 0 1
      packages/app/public/static/locales/en_US/translation.json
  30. 2 2
      packages/app/public/static/locales/ja_JP/admin.json
  31. 2 2
      packages/app/public/static/locales/zh_CN/admin.json
  32. 0 1
      packages/app/public/static/locales/zh_CN/translation.json
  33. 1 1
      packages/app/resource/locales/en_US/sandbox-diagrams.md
  34. 1 1
      packages/app/resource/locales/ja_JP/sandbox-diagrams.md
  35. 1 1
      packages/app/resource/locales/zh_CN/sandbox-diagrams.md
  36. 50 37
      packages/app/src/client/services/page-operation.ts
  37. 0 21
      packages/app/src/client/util/editor.ts
  38. 2 2
      packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  39. 2 2
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  40. 1 1
      packages/app/src/components/BookmarkButtons.tsx
  41. 0 117
      packages/app/src/components/Drawio.tsx
  42. 1 1
      packages/app/src/components/LikeButtons.tsx
  43. 6 0
      packages/app/src/components/Page.module.scss
  44. 160 157
      packages/app/src/components/Page.tsx
  45. 41 61
      packages/app/src/components/PageEditor.tsx
  46. 34 21
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  47. 1 1
      packages/app/src/components/PageEditor/ConflictDiffModal.tsx
  48. 18 5
      packages/app/src/components/PageEditor/DrawioCommunicationHelper.ts
  49. 3 7
      packages/app/src/components/PageEditor/DrawioModal.tsx
  50. 10 2
      packages/app/src/components/PageEditor/HandsontableModal.tsx
  51. 7 7
      packages/app/src/components/PageEditor/MarkdownDrawioUtil.js
  52. 5 0
      packages/app/src/components/PageEditor/MarkdownTableUtil.js
  53. 38 47
      packages/app/src/components/PageEditorByHackmd.tsx
  54. 12 24
      packages/app/src/components/PageRenameModal.tsx
  55. 19 0
      packages/app/src/components/ReactMarkdownComponents/DrawioViewerWithEditButton.module.scss
  56. 70 0
      packages/app/src/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx
  57. 16 3
      packages/app/src/components/ReactMarkdownComponents/Header.tsx
  58. 25 0
      packages/app/src/components/ReactMarkdownComponents/TableWithEditButton.module.scss
  59. 52 0
      packages/app/src/components/ReactMarkdownComponents/TableWithEditButton.tsx
  60. 7 2
      packages/app/src/components/SavePageControls.tsx
  61. 38 0
      packages/app/src/components/Script/DrawioViewerScript.tsx
  62. 1 1
      packages/app/src/components/SubscribeButton.tsx
  63. 1 1
      packages/app/src/components/User/SeenUserInfo.tsx
  64. 0 10
      packages/app/src/interfaces/editor-settings.ts
  65. 0 8
      packages/app/src/interfaces/global.ts
  66. 0 11
      packages/app/src/interfaces/graph-viewer.ts
  67. 10 0
      packages/app/src/interfaces/page-operation.ts
  68. 6 0
      packages/app/src/interfaces/rehype.ts
  69. 17 7
      packages/app/src/pages/[[...path]].page.tsx
  70. 13 3
      packages/app/src/pages/_document.page.tsx
  71. 10 1
      packages/app/src/pages/_private-legacy-pages.page.tsx
  72. 10 1
      packages/app/src/pages/_search.page.tsx
  73. 98 87
      packages/app/src/pages/share/[[...path]].page.tsx
  74. 1 1
      packages/app/src/server/crowi/index.js
  75. 5 0
      packages/app/src/server/models/config.ts
  76. 1 1
      packages/app/src/server/service/config-loader.ts
  77. 0 38
      packages/app/src/server/views/widget/headers/drawio.html
  78. 0 156
      packages/app/src/services/renderer/interceptor/drawio-interceptor.js
  79. 19 6
      packages/app/src/services/renderer/renderer.tsx
  80. 2 6
      packages/app/src/stores/context.tsx
  81. 6 1
      packages/app/src/stores/editor.tsx
  82. 2 1
      packages/app/src/stores/middlewares/sync-to-storage.ts
  83. 62 19
      packages/app/src/stores/modal.tsx
  84. 0 41
      packages/app/src/styles/_page.scss
  85. 7 0
      packages/app/src/styles/theme/_apply-colors-dark.scss
  86. 7 0
      packages/app/src/styles/theme/_apply-colors-light.scss
  87. 33 25
      packages/app/test/cypress/integration/20-basic-features/access-to-page.spec.ts
  88. 11 1
      packages/app/test/cypress/integration/20-basic-features/access-to-pagelist.spec.ts
  89. 121 59
      packages/app/test/cypress/integration/20-basic-features/click-page-icons.spec.ts
  90. 93 50
      packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts
  91. 3 0
      packages/app/test/cypress/integration/40-admin/access-to-admin-page.spec.ts
  92. 234 176
      packages/app/test/cypress/integration/50-sidebar/access-to-side-bar.spec.ts
  93. 8 4
      packages/app/test/cypress/integration/50-sidebar/switching-sidebar-mode.spec.ts
  94. 1 1
      packages/app/tsconfig.build.client.json
  95. 1 1
      packages/app/tsconfig.build.server.json
  96. 1 1
      packages/app/tsconfig.json
  97. 0 1
      packages/core/src/index.ts
  98. 0 69
      packages/core/src/plugin/service/tag-cache-manager.js
  99. 0 125
      packages/core/src/test/plugin/service/tag-cache-manager.test.js
  100. 0 265
      packages/plugin-lsx/src/components/Lsx.tsx

+ 2 - 4
.github/workflows/ci-app-prod.yml

@@ -14,10 +14,9 @@ on:
       - '!packages/app/docker/**'
       - packages/codemirror-textlint/**
       - packages/core/**
-      - packages/remark-growi-plugin/**
+      - packages/remark-*/**
       - packages/slack/**
       - packages/ui/**
-      - packages/plugin-**
   pull_request:
     branches:
       - master
@@ -32,10 +31,9 @@ on:
       - '!packages/app/docker/**'
       - packages/codemirror-textlint/**
       - packages/core/**
-      - packages/remark-growi-plugin/**
+      - packages/remark-*/**
       - packages/slack/**
       - packages/ui/**
-      - packages/plugin-**
   workflow_call:
     inputs:
       cypress-config-video:

+ 3 - 4
.github/workflows/ci-app.yml

@@ -16,10 +16,9 @@ on:
       - '!packages/app/docker/**'
       - packages/codemirror-textlint/**
       - packages/core/**
-      - packages/remark-growi-plugin/**
+      - packages/remark-*/**
       - packages/slack/**
       - packages/ui/**
-      - packages/plugin-*/**
 
 jobs:
   lint:
@@ -55,7 +54,7 @@ jobs:
 
       - name: lerna run lint for plugins
         run: |
-          yarn lerna run lint --scope @growi/remark-growi-plugin --scope @growi/plugin-*
+          yarn lerna run lint --scope @growi/remark-*
       - name: lerna run lint for app
         run: |
           yarn lerna run lint --scope @growi/app --scope @growi/codemirror-textlint --scope @growi/core --scope @growi/slack --scope @growi/ui
@@ -109,7 +108,7 @@ jobs:
 
       - name: lerna run test for plugins
         run: |
-          yarn lerna run test --scope @growi/remark-growi-plugin --scope @growi/plugin-*
+          yarn lerna run test --scope @growi/remark-*
 
       - name: Test app
         working-directory: ./packages/app

+ 2 - 6
.vscode/launch.json

@@ -73,12 +73,8 @@
             "path": "${workspaceFolder}/packages/core"
           },
           {
-            "url": "webpack://_n_e/plugin-attachment-refs",
-            "path": "${workspaceFolder}/packages/plugin-attachment-refs"
-          },
-          {
-            "url": "webpack://_n_e/plugin-lsx",
-            "path": "${workspaceFolder}/packages/plugin-lsx"
+            "url": "webpack://_n_e/remark-lsx",
+            "path": "${workspaceFolder}/packages/remark-lsx"
           },
           {
             "url": "webpack://_n_e/slack",

+ 0 - 0
packages/plugin-attachment-refs/.eslintignore → packages-obsolete/plugin-attachment-refs/.eslintignore


+ 0 - 0
packages/plugin-attachment-refs/.gitignore → packages-obsolete/plugin-attachment-refs/.gitignore


+ 0 - 0
packages/plugin-attachment-refs/README.md → packages-obsolete/plugin-attachment-refs/README.md


+ 0 - 0
packages/plugin-attachment-refs/package.json → packages-obsolete/plugin-attachment-refs/package.json


+ 0 - 0
packages/plugin-attachment-refs/src/client-entry.js → packages-obsolete/plugin-attachment-refs/src/client-entry.js


+ 0 - 0
packages/plugin-attachment-refs/src/client/css/index.css → packages-obsolete/plugin-attachment-refs/src/client/css/index.css


+ 0 - 0
packages/plugin-attachment-refs/src/client/js/components/AttachmentList.jsx → packages-obsolete/plugin-attachment-refs/src/client/js/components/AttachmentList.jsx


+ 0 - 0
packages/plugin-attachment-refs/src/client/js/components/ExtractedAttachments.jsx → packages-obsolete/plugin-attachment-refs/src/client/js/components/ExtractedAttachments.jsx


+ 0 - 0
packages/plugin-attachment-refs/src/client/js/util/GalleryContext.js → packages-obsolete/plugin-attachment-refs/src/client/js/util/GalleryContext.js


+ 0 - 0
packages/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js → packages-obsolete/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js


+ 0 - 0
packages/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPreRenderInterceptor.js → packages-obsolete/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPreRenderInterceptor.js


+ 0 - 0
packages/plugin-attachment-refs/src/client/js/util/RefsContext.js → packages-obsolete/plugin-attachment-refs/src/client/js/util/RefsContext.js


+ 0 - 0
packages/plugin-attachment-refs/src/client/js/util/TagCacheManagerFactory.js → packages-obsolete/plugin-attachment-refs/src/client/js/util/TagCacheManagerFactory.js


+ 0 - 0
packages/plugin-attachment-refs/src/index.js → packages-obsolete/plugin-attachment-refs/src/index.js


+ 0 - 0
packages/plugin-attachment-refs/src/server-entry.js → packages-obsolete/plugin-attachment-refs/src/server-entry.js


+ 0 - 0
packages/plugin-attachment-refs/src/server/routes/index.js → packages-obsolete/plugin-attachment-refs/src/server/routes/index.js


+ 0 - 0
packages/plugin-attachment-refs/src/server/routes/refs.js → packages-obsolete/plugin-attachment-refs/src/server/routes/refs.js


+ 0 - 0
packages/plugin-attachment-refs/src/utils/logger/index.ts → packages-obsolete/plugin-attachment-refs/src/utils/logger/index.ts


+ 0 - 0
packages/plugin-attachment-refs/tsconfig.base.json → packages-obsolete/plugin-attachment-refs/tsconfig.base.json


+ 0 - 0
packages/plugin-attachment-refs/tsconfig.build.cjs.json → packages-obsolete/plugin-attachment-refs/tsconfig.build.cjs.json


+ 0 - 0
packages/plugin-attachment-refs/tsconfig.build.esm.json → packages-obsolete/plugin-attachment-refs/tsconfig.build.esm.json


+ 0 - 0
packages/plugin-attachment-refs/tsconfig.json → packages-obsolete/plugin-attachment-refs/tsconfig.json


+ 2 - 2
packages/app/docker/Dockerfile

@@ -105,11 +105,11 @@ COPY ["package.json", "lerna.json", "tsconfig.base.json", "./"]
 COPY packages/app packages/app
 COPY packages/core packages/core
 COPY packages/codemirror-textlint packages/codemirror-textlint
-COPY packages/plugin-attachment-refs packages/plugin-attachment-refs
-COPY packages/plugin-lsx packages/plugin-lsx
 COPY packages/slack packages/slack
 COPY packages/ui packages/ui
+COPY packages/remark-drawio-plugin packages/remark-drawio-plugin
 COPY packages/remark-growi-plugin packages/remark-growi-plugin
+COPY packages/remark-lsx packages/remark-lsx
 COPY packages/hackmd packages/hackmd
 
 # build

+ 3 - 2
packages/app/package.json

@@ -68,8 +68,9 @@
     "@growi/codemirror-textlint": "^6.0.0-RC.9",
     "@growi/core": "^6.0.0-RC.9",
     "@growi/hackmd": "^6.0.0-RC.9",
-    "@growi/plugin-attachment-refs": "^6.0.0-RC.9",
-    "@growi/plugin-lsx": "^6.0.0-RC.9",
+    "@growi/remark-drawio-plugin": "^6.0.0-RC.9",
+    "@growi/remark-growi-plugin": "^6.0.0-RC.9",
+    "@growi/remark-lsx": "^6.0.0-RC.9",
     "@growi/slack": "^6.0.0-RC.9",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",

+ 2 - 2
packages/app/public/static/locales/en_US/admin.json

@@ -16,8 +16,8 @@
     "always_displayed": "Always displayed",
     "displayed_or_hidden": "Displayed / Hidden",
     "Fixed by env var": "This is fixed by the env var <code>{{key}}={{value}}</code>.",
-    "Register limitation": "Register limitation",
-    "Register limitation desc": "Restriction of new users' registration",
+    "register_limitation": "Register limitation",
+    "register_limitation_desc": "Restriction of new users' registration",
     "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
     "users_without_account": "Users without account is not accessible",
     "example": "Example",

+ 0 - 1
packages/app/public/static/locales/en_US/translation.json

@@ -116,7 +116,6 @@
   "UserGroup": "UserGroup",
   "Basic Settings": "Basic Settings",
   "Basic authentication": "Basic authentication",
-  "Register limitation": "Register limitation",
   "The contents entered here will be shown in the header etc": "The contents entered here will be shown in the header etc",
   "Public": "Public",
   "Anyone with the link": "Anyone with the link",

+ 2 - 2
packages/app/public/static/locales/ja_JP/admin.json

@@ -26,8 +26,8 @@
     "always_displayed": "表示 (固定)",
     "displayed_or_hidden": "表示 / 非表示",
     "Fixed by env var": "環境変数 <code>{{forcewikimode}}={{wikimode}}</code> により固定されています。",
-    "Register limitation": "登録の制限",
-    "Register limitation desc": "新しいユーザーを登録する方法を制限します.",
+    "register_limitation": "登録の制限",
+    "register_limitation_desc": "新しいユーザーを登録する方法を制限します。",
     "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
     "users_without_account": "アカウントを持たないユーザーはアクセス不可",
     "example": "例",

+ 2 - 2
packages/app/public/static/locales/zh_CN/admin.json

@@ -25,8 +25,8 @@
     "displayed_or_hidden": "显示/隐藏",
     "Guest Users Access": "来宾用户访问",
 		"Fixed by env var": "这是由env var<code>%s=%s</code>修复的。",
-		"Register limitation": "注册限制",
-		"Register limitation desc": "限制新用户注册",
+		"register_limitation": "注册限制",
+		"register_limitation_desc": "限制新用户注册",
 		"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
 		"users_without_account": "无法访问没有帐户的用户",
 		"example": "例子",

+ 0 - 1
packages/app/public/static/locales/zh_CN/translation.json

@@ -123,7 +123,6 @@
   "ChildUserGroup": "儿童用户组",
 	"Basic Settings": "基础设置",
 	"Basic authentication": "基本身份验证",
-	"Register limitation": "注册限制",
 	"The contents entered here will be shown in the header etc": "此处输入的内容将显示在标题等中",
 	"Public": "公共",
 	"Anyone with the link": "任何人",

Разница между файлами не показана из-за своего большого размера
+ 1 - 1
packages/app/resource/locales/en_US/sandbox-diagrams.md


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
packages/app/resource/locales/ja_JP/sandbox-diagrams.md


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
packages/app/resource/locales/zh_CN/sandbox-diagrams.md


+ 50 - 37
packages/app/src/client/services/page-operation.ts

@@ -1,7 +1,8 @@
 import { SubscriptionStatusType, Nullable } from '@growi/core';
 import urljoin from 'url-join';
 
-import { OptionsToSave } from '~/interfaces/editor-settings';
+import { OptionsToSave } from '~/interfaces/page-operation';
+import { useIsEnabledUnsavedWarning } from '~/stores/editor';
 import loggerFactory from '~/utils/logger';
 
 import { toastError } from '../util/apiNotification';
@@ -118,43 +119,55 @@ type PageInfo= {
   revisionId: Nullable<string>,
 }
 
+type SaveOrUpdateFunction = (markdown: string, pageInfo: PageInfo, optionsToSave?: OptionsToSave) => any;
+
 // TODO: define return type
-export const saveOrUpdate = async(optionsToSave: OptionsToSave, pageInfo: PageInfo, markdown: string) => {
-  const { path, pageId, revisionId } = pageInfo;
-
-  const options = Object.assign({}, optionsToSave);
-
-  /*
-  * Note: variable "markdown" will be received from params
-  * please delete the following code after implemating HackMD editor function
-  */
-  // let markdown;
-  // if (editorMode === EditorMode.HackMD) {
-  // const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
-  // markdown = await pageEditorByHackmd.getMarkdown();
-  // // set option to sync
-  // options.isSyncRevisionToHackmd = true;
-  // revisionId = this.state.revisionIdHackmdSynced;
-  // }
-  // else {
-  // const pageEditor = this.appContainer.getComponentInstance('PageEditor');
-  // const pageEditor = getComponentInstance('PageEditor');
-  // markdown = pageEditor.getMarkdown();
-  // }
-
-  const isNoRevisionPage = pageId != null && revisionId == null;
-
-  let res;
-  if (pageId == null || isNoRevisionPage) {
-    res = await createPage(path, markdown, options);
-  }
-  else {
-    if (revisionId == null) {
-      const msg = '\'revisionId\' is required to update page';
-      throw new Error(msg);
+export const useSaveOrUpdate = (): SaveOrUpdateFunction => {
+  /* eslint-disable react-hooks/rules-of-hooks */
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+  /* eslint-enable react-hooks/rules-of-hooks */
+
+  return async function(markdown: string, pageInfo: PageInfo, optionsToSave?: OptionsToSave) {
+    const { path, pageId, revisionId } = pageInfo;
+
+    const options: OptionsToSave = Object.assign({}, optionsToSave);
+    /*
+    * Note: variable "markdown" will be received from params
+    * please delete the following code after implemating HackMD editor function
+    */
+    // let markdown;
+    // if (editorMode === EditorMode.HackMD) {
+    // const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd');
+    // markdown = await pageEditorByHackmd.getMarkdown();
+    // // set option to sync
+    // options.isSyncRevisionToHackmd = true;
+    // revisionId = this.state.revisionIdHackmdSynced;
+    // }
+    // else {
+    // const pageEditor = this.appContainer.getComponentInstance('PageEditor');
+    // const pageEditor = getComponentInstance('PageEditor');
+    // markdown = pageEditor.getMarkdown();
+    // }
+
+    const isNoRevisionPage = pageId != null && revisionId == null;
+
+    let res;
+    if (pageId == null || isNoRevisionPage) {
+      res = await createPage(path, markdown, options);
+    }
+    else {
+      if (revisionId == null) {
+        const msg = '\'revisionId\' is required to update page';
+        throw new Error(msg);
+      }
+      res = await updatePage(pageId, revisionId, markdown, options);
     }
-    res = await updatePage(pageId, revisionId, markdown, options);
-  }
 
-  return res;
+    // The updateFn should be a promise or asynchronous function to handle the remote mutation
+    // it should return updated data. see: https://swr.vercel.app/docs/mutation#optimistic-updates
+    // Moreover, `async() => false` does not work since it's too fast to be calculated.
+    await mutateIsEnabledUnsavedWarning(new Promise(r => setTimeout(() => r(false), 10)), { optimisticData: () => false });
+
+    return res;
+  };
 };

+ 0 - 21
packages/app/src/client/util/editor.ts

@@ -1,21 +0,0 @@
-import type { OptionsToSave } from '~/interfaces/editor-settings';
-
-export const getOptionsToSave = (
-    isSlackEnabled: boolean,
-    slackChannels: string,
-    grant: number,
-    grantUserGroupId: string | null | undefined,
-    grantUserGroupName: string | null | undefined,
-    pageTags: string[],
-    isSyncRevisionToHackmd?: boolean,
-): OptionsToSave => {
-  return {
-    pageTags,
-    isSlackEnabled,
-    slackChannels,
-    grant,
-    grantUserGroupId,
-    grantUserGroupName,
-    isSyncRevisionToHackmd,
-  };
-};

+ 2 - 2
packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx

@@ -100,7 +100,7 @@ class LocalSecuritySettingContents extends React.Component {
 
             <div className="row">
               <div className="col-12 col-md-3 text-left text-md-right py-2">
-                <strong>{t('security_settings.Register limitation')}</strong>
+                <strong>{t('security_settings.register_limitation')}</strong>
               </div>
               <div className="col-12 col-md-6">
                 <div className="dropdown">
@@ -147,7 +147,7 @@ class LocalSecuritySettingContents extends React.Component {
                   </div>
                 </div>
 
-                <p className="form-text text-muted small">{t('security_settings.Register limitation desc')}</p>
+                <p className="form-text text-muted small">{t('security_settings.register_limitation_desc')}</p>
               </div>
             </div>
             <div className="row">

+ 2 - 2
packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -126,7 +126,7 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
   }, [props.userGroupRelations, props.childUserGroups]);
 
   return (
-    <>
+    <div data-testid="grw-user-group-table">
       <h2>{props.headerLabel}</h2>
 
       <table className="table table-bordered table-user-list">
@@ -216,6 +216,6 @@ export const UserGroupTable: FC<Props> = (props: Props) => {
           })}
         </tbody>
       </table>
-    </>
+    </div>
   );
 };

+ 1 - 1
packages/app/src/components/BookmarkButtons.tsx

@@ -63,7 +63,7 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
         <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
       </button>
 
-      <UncontrolledTooltip placement="top" target="bookmark-button" fade={false}>
+      <UncontrolledTooltip data-testid="bookmark-button-tooltip" placement="top" target="bookmark-button" fade={false}>
         {t(getTooltipMessage())}
       </UncontrolledTooltip>
 

+ 0 - 117
packages/app/src/components/Drawio.tsx

@@ -1,117 +0,0 @@
-import React, {
-  useCallback, useEffect, useMemo, useRef, useState,
-} from 'react';
-
-import EventEmitter from 'events';
-
-import { useTranslation } from 'next-i18next';
-import { debounce } from 'throttle-debounce';
-
-import { CustomWindow } from '~/interfaces/global';
-import { IGraphViewer, isGraphViewer } from '~/interfaces/graph-viewer';
-
-import NotAvailableForGuest from './NotAvailableForGuest';
-
-type Props = {
-  GraphViewer: IGraphViewer,
-  drawioContent: string,
-  rangeLineNumberOfMarkdown: { beginLineNumber: number, endLineNumber: number },
-  isPreview?: boolean,
-}
-
-// It calls callback when GraphViewer is not null.
-// eslint-disable-next-line @typescript-eslint/ban-types
-const waitForGraphViewer = async(callback: Function) => {
-  const MAX_WAIT_COUNT = 10; // no reason for 10
-
-  for (let i = 0; i < MAX_WAIT_COUNT; i++) {
-    if (isGraphViewer((window as CustomWindow).GraphViewer)) {
-      callback((window as CustomWindow).GraphViewer);
-      break;
-    }
-    // Sleep 500 ms
-    // eslint-disable-next-line no-await-in-loop
-    await new Promise<void>(r => setTimeout(() => r(), 500));
-  }
-};
-
-const Drawio = (props: Props): JSX.Element => {
-
-  const { t } = useTranslation();
-
-  // Wrap with a function since GraphViewer is a function.
-  // This applies when call setGraphViewer as well.
-  const [GraphViewer, setGraphViewer] = useState<IGraphViewer | undefined>(() => (window as CustomWindow).GraphViewer);
-
-  const { drawioContent, rangeLineNumberOfMarkdown, isPreview } = props;
-
-  // const { open: openDrawioModal } = useDrawioModalForPage();
-
-  const drawioContainerRef = useRef<HTMLDivElement>(null);
-
-  const globalEmitter: EventEmitter = (window as CustomWindow).globalEmitter;
-
-  const editButtonClickHandler = useCallback(() => {
-    const { beginLineNumber, endLineNumber } = rangeLineNumberOfMarkdown;
-    globalEmitter.emit('launchDrawioModal', beginLineNumber, endLineNumber);
-  }, [rangeLineNumberOfMarkdown, globalEmitter]);
-
-  const renderDrawio = useCallback((GraphViewer: IGraphViewer) => {
-    if (drawioContainerRef.current == null) {
-      return;
-    }
-
-    const mxgraphs = drawioContainerRef.current.getElementsByClassName('mxgraph');
-    if (mxgraphs.length > 0) {
-      // GROWI では、mxgraph element は最初のものをレンダリングする前提とする
-      const div = mxgraphs[0];
-
-      if (div != null) {
-        div.innerHTML = '';
-        GraphViewer.createViewerForElement(div);
-      }
-    }
-  }, [drawioContainerRef]);
-
-  const renderDrawioWithDebounce = useMemo(() => debounce(200, renderDrawio), [renderDrawio]);
-
-  useEffect(() => {
-    if (GraphViewer == null) {
-      waitForGraphViewer((gv: IGraphViewer) => {
-        setGraphViewer(() => gv);
-      });
-      return;
-    }
-
-    renderDrawioWithDebounce(GraphViewer);
-  }, [renderDrawioWithDebounce, GraphViewer]);
-
-  return (
-    <div className="editable-with-drawio position-relative">
-      { !isPreview && (
-        <NotAvailableForGuest>
-          <button type="button" className="drawio-iframe-trigger position-absolute btn btn-outline-secondary" onClick={editButtonClickHandler}>
-            <i className="icon-note mr-1"></i>{t('Edit')}
-          </button>
-        </NotAvailableForGuest>
-      ) }
-      <div
-        className="drawio"
-        style={
-          {
-            borderRadius: 3,
-            border: '1px solid #d7d7d7',
-            margin: '20px 0',
-          }
-        }
-        ref={drawioContainerRef}
-        // eslint-disable-next-line react/no-danger
-        dangerouslySetInnerHTML={{ __html: drawioContent }}
-      >
-      </div>
-    </div>
-  );
-
-};
-
-export default Drawio;

+ 1 - 1
packages/app/src/components/LikeButtons.tsx

@@ -56,7 +56,7 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
         <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>
       </button>
 
-      <UncontrolledTooltip placement="top" target="like-button" fade={false}>
+      <UncontrolledTooltip data-testid="like-button-tooltip" placement="top" target="like-button" autohide={false} fade={false}>
         {t(getTooltipMessage())}
       </UncontrolledTooltip>
 

+ 6 - 0
packages/app/src/components/Page.module.scss

@@ -0,0 +1,6 @@
+// mobile
+.page-mobile :global {
+  .editable-with-handsontable .handsontable-modal-trigger {
+    opacity: 0.3;
+  }
+}

+ 160 - 157
packages/app/src/components/Page.tsx

@@ -1,37 +1,43 @@
 import React, {
   useCallback,
-  useEffect, useRef, useState,
+  useEffect, useRef,
 } from 'react';
 
 import EventEmitter from 'events';
 
+import { DrawioEditByViewerProps } from '@growi/remark-drawio-plugin';
+import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
-// import { debounce } from 'throttle-debounce';
-
 import { HtmlElementNode } from 'rehype-toc';
 
+import MarkdownTable from '~/client/models/MarkdownTable';
+import { useSaveOrUpdate } from '~/client/services/page-operation';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { getOptionsToSave } from '~/client/util/editor';
+import { OptionsToSave } from '~/interfaces/page-operation';
 import {
   useIsGuestUser, useShareLinkId,
 } from '~/stores/context';
-import {
-  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
-} from '~/stores/editor';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useEditingMarkdown } from '~/stores/editor';
+import { useDrawioModal, useHandsontableModal } from '~/stores/modal';
+import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import { useViewOptions } from '~/stores/renderer';
 import {
   useCurrentPageTocNode,
-  useEditorMode, useIsMobile,
+  useIsMobile,
 } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
 import RevisionRenderer from './Page/RevisionRenderer';
-import { DrawioModal } from './PageEditor/DrawioModal';
 import mdu from './PageEditor/MarkdownDrawioUtil';
+import mtu from './PageEditor/MarkdownTableUtil';
+
+import styles from './Page.module.scss';
 
 
-declare const globalEmitter: EventEmitter;
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
 
 // const DrawioModal = dynamic(() => import('./PageEditor/DrawioModal'), { ssr: false });
 const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
@@ -39,118 +45,10 @@ const LinkEditModal = dynamic(() => import('./PageEditor/LinkEditModal'), { ssr:
 
 const logger = loggerFactory('growi:Page');
 
-type PageSubstanceProps = {
-  rendererOptions: any,
-  page: any,
-  pageTags?: string[],
-  editorMode: string,
-  isGuestUser: boolean,
-  isMobile?: boolean,
-  isSlackEnabled: boolean,
-  slackChannels: string,
-};
-
-class PageSubstance extends React.Component<PageSubstanceProps> {
-
-  gridEditModal: any;
-
-  linkEditModal: any;
-
-  drawioModal: any;
-
-  constructor(props: PageSubstanceProps) {
-    super(props);
-
-    this.state = {
-      currentTargetTableArea: null,
-      currentTargetDrawioArea: null,
-    };
-
-    this.gridEditModal = React.createRef();
-    this.linkEditModal = React.createRef();
-    this.drawioModal = React.createRef();
-
-    this.saveHandlerForDrawioModal = this.saveHandlerForDrawioModal.bind(this);
-  }
-
-  /**
-   * launch DrawioModal with data specified by arguments
-   * @param beginLineNumber
-   * @param endLineNumber
-   */
-  launchDrawioModal(beginLineNumber, endLineNumber) {
-    // const markdown = this.props.pageContainer.state.markdown;
-    // const drawioMarkdownArray = markdown.split(/\r\n|\r|\n/).slice(beginLineNumber - 1, endLineNumber);
-    // const drawioData = drawioMarkdownArray.slice(1, drawioMarkdownArray.length - 1).join('\n').trim();
-    // this.setState({ currentTargetDrawioArea: { beginLineNumber, endLineNumber } });
-    // this.drawioModal.current.show(drawioData);
-  }
-
-  async saveHandlerForDrawioModal(drawioData) {
-  //   const {
-  //     isSlackEnabled, slackChannels, pageContainer, pageTags, grant, grantGroupId, grantGroupName, mutateIsEnabledUnsavedWarning,
-  //   } = this.props;
-  //   const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
-
-    //   const newMarkdown = mdu.replaceDrawioInMarkdown(
-    //     drawioData,
-    //     this.props.pageContainer.state.markdown,
-    //     this.state.currentTargetDrawioArea.beginLineNumber,
-    //     this.state.currentTargetDrawioArea.endLineNumber,
-    //   );
-
-    //   try {
-    //     // disable unsaved warning
-    //     mutateIsEnabledUnsavedWarning(false);
-
-    //     // eslint-disable-next-line no-unused-vars
-    //     const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
-    //     logger.debug('success to save');
-
-    // // Todo: add translation
-    //   toastSuccess(t(''));
-    //   }
-  //   catch (error) {
-  //     logger.error('failed to save', error);
-  //     toastError(error);
-  //   }
-  //   finally {
-  //     this.setState({ currentTargetDrawioArea: null });
-  //   }
-  }
-
-  override render() {
-    const {
-      rendererOptions, page, isMobile, isGuestUser,
-    } = this.props;
-    const { path } = page;
-    const { _id: revisionId, body: markdown } = page.revision;
-
-    return (
-      <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
-
-        { revisionId != null && (
-          <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
-        )}
-
-        { !isGuestUser && (
-          <>
-            <GridEditModal ref={this.gridEditModal} />
-            <LinkEditModal ref={this.linkEditModal} />
-            {/* TODO: use global DrawioModal https://redmine.weseek.co.jp/issues/105981 */}
-            {/* <DrawioModal
-              ref={this.drawioModal}
-              onSave={this.saveHandlerForDrawioModal}
-            /> */}
-          </>
-        )}
-      </div>
-    );
-  }
-
-}
 
 export const Page = (props) => {
+  const { t } = useTranslation();
+
   // Pass tocRef to generateViewOptions (=> rehypePlugin => customizeTOC) to call mutateCurrentPageTocNode when tocRef.current changes.
   // The toc node passed by customizeTOC is assigned to tocRef.current.
   const tocRef = useRef<HtmlElementNode>();
@@ -160,41 +58,143 @@ export const Page = (props) => {
   }, []);
 
   const { data: shareLinkId } = useShareLinkId();
-  const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
-  const { data: editorMode } = useEditorMode();
+  const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
+  const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
+  const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isMobile } = useIsMobile();
-  const { data: slackChannelsData } = useSWRxSlackChannels(currentPage?.path);
-  const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: pageTags } = usePageTagsForEditors(null); // TODO: pass pageId
   const { data: rendererOptions } = useViewOptions(storeTocNodeHandler);
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
+  const { open: openDrawioModal } = useDrawioModal();
+  const { open: openHandsontableModal } = useHandsontableModal();
+
+  const saveOrUpdate = useSaveOrUpdate();
 
-  const pageRef = useRef(null);
 
   useEffect(() => {
     mutateCurrentPageTocNode(tocRef.current);
   // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [mutateCurrentPageTocNode, tocRef.current]); // include tocRef.current to call mutateCurrentPageTocNode when tocRef.current changes
 
-  // // set handler to open DrawioModal
-  // useEffect(() => {
-  //   const handler = (beginLineNumber, endLineNumber) => {
-  //     if (pageRef?.current != null) {
-  //       pageRef.current.launchDrawioModal(beginLineNumber, endLineNumber);
-  //     }
-  //   };
-  //   window.globalEmitter.on('launchDrawioModal', handler);
-
-  //   return function cleanup() {
-  //     window.globalEmitter.removeListener('launchDrawioModal', handler);
-  //   };
-  // }, []);
-
-  if (currentPage == null || editorMode == null || isGuestUser == null || rendererOptions == null) {
+
+  // TODO: refactor commonize saveByDrawioModal and saveByHandsontableModal
+  const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
+    if (currentPage == null || tagsInfo == null) {
+      return;
+    }
+
+    // disable if share link
+    if (shareLinkId != null) {
+      return;
+    }
+
+    const currentMarkdown = currentPage.revision.body;
+    const optionsToSave: OptionsToSave = {
+      isSlackEnabled: false,
+      slackChannels: '',
+      grant: currentPage.grant,
+      grantUserGroupId: currentPage.grantedGroup?._id,
+      grantUserGroupName: currentPage.grantedGroup?.name,
+      pageTags: tagsInfo.tags,
+    };
+
+    const newMarkdown = mdu.replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
+
+    try {
+      const currentRevisionId = currentPage.revision._id;
+      await saveOrUpdate(
+        newMarkdown,
+        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
+        optionsToSave,
+      );
+
+      toastSuccess(t('toaster.save_succeeded'));
+
+      // rerender
+      mutateCurrentPage();
+      mutateEditingMarkdown(newMarkdown);
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      toastError(error);
+    }
+  }, [currentPage, mutateCurrentPage, mutateEditingMarkdown, saveOrUpdate, shareLinkId, t, tagsInfo]);
+
+  // set handler to open DrawioModal
+  useEffect(() => {
+    // disable if share link
+    if (shareLinkId != null) {
+      return;
+    }
+
+    const handler = (data: DrawioEditByViewerProps) => {
+      openDrawioModal(data.drawioMxFile, drawioMxFile => saveByDrawioModal(drawioMxFile, data.bol, data.eol));
+    };
+    globalEmitter.on('launchDrawioModal', handler);
+
+    return function cleanup() {
+      globalEmitter.removeListener('launchDrawioModal', handler);
+    };
+  }, [openDrawioModal, saveByDrawioModal, shareLinkId]);
+
+  const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
+    if (currentPage == null || tagsInfo == null || shareLinkId != null) {
+      return;
+    }
+
+    const currentMarkdown = currentPage.revision.body;
+    const optionsToSave: OptionsToSave = {
+      isSlackEnabled: false,
+      slackChannels: '',
+      grant: currentPage.grant,
+      grantUserGroupId: currentPage.grantedGroup?._id,
+      grantUserGroupName: currentPage.grantedGroup?.name,
+      pageTags: tagsInfo.tags,
+    };
+
+    const newMarkdown = mtu.replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
+
+    try {
+      const currentRevisionId = currentPage.revision._id;
+      await saveOrUpdate(
+        newMarkdown,
+        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
+        optionsToSave,
+      );
+
+      toastSuccess(t('toaster.save_succeeded'));
+
+      // rerender
+      mutateCurrentPage();
+      mutateEditingMarkdown(newMarkdown);
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      toastError(error);
+    }
+  }, [currentPage, mutateCurrentPage, mutateEditingMarkdown, saveOrUpdate, shareLinkId, t, tagsInfo]);
+
+  // set handler to open HandsonTableModal
+  useEffect(() => {
+    if (currentPage == null || shareLinkId != null) {
+      return;
+    }
+
+    const handler = (bol: number, eol: number) => {
+      const markdown = currentPage.revision.body;
+      const currentMarkdownTable = mtu.getMarkdownTableFromLine(markdown, bol, eol);
+      openHandsontableModal(currentMarkdownTable, undefined, false, table => saveByHandsontableModal(table, bol, eol));
+    };
+    globalEmitter.on('launchHandsonTableModal', handler);
+
+    return function cleanup() {
+      globalEmitter.removeListener('launchHandsonTableModal', handler);
+    };
+  }, [currentPage, openHandsontableModal, saveByHandsontableModal, shareLinkId]);
+
+  if (currentPage == null || isGuestUser == null || rendererOptions == null) {
     const entries = Object.entries({
-      currentPage, editorMode, isGuestUser, rendererOptions,
+      currentPage, isGuestUser, rendererOptions,
     })
       .map(([key, value]) => [key, value == null ? 'null' : undefined])
       .filter(([, value]) => value != null);
@@ -203,19 +203,22 @@ export const Page = (props) => {
     return null;
   }
 
+  const { _id: revisionId, body: markdown } = currentPage.revision;
+
   return (
-    <PageSubstance
-      {...props}
-      ref={pageRef}
-      rendererOptions={rendererOptions}
-      page={currentPage}
-      editorMode={editorMode}
-      isGuestUser={isGuestUser}
-      isMobile={isMobile}
-      isSlackEnabled={isSlackEnabled}
-      pageTags={pageTags}
-      slackChannels={slackChannelsData?.toString()}
-      mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
-    />
+    <div className={`mb-5 ${isMobile ? `page-mobile ${styles['page-mobile']}` : ''}`}>
+
+      { revisionId != null && (
+        <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
+      )}
+
+      { !isGuestUser && (
+        <>
+          <GridEditModal />
+          <LinkEditModal />
+        </>
+      )}
+    </div>
   );
+
 };

+ 41 - 61
packages/app/src/components/PageEditor.tsx

@@ -5,31 +5,32 @@ import React, {
 import EventEmitter from 'events';
 
 import {
-  envUtils, IPageHasId, PageGrant, pathUtils,
+  IPageHasId, PageGrant, pathUtils,
 } from '@growi/core';
 import detectIndent from 'detect-indent';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { throttle, debounce } from 'throttle-debounce';
 
-import { saveOrUpdate } from '~/client/services/page-operation';
+import { useSaveOrUpdate } from '~/client/services/page-operation';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
-import { getOptionsToSave } from '~/client/util/editor';
 import { IEditorMethods } from '~/interfaces/editor-methods';
+import { OptionsToSave } from '~/interfaces/page-operation';
 import {
   useCurrentPathname, useCurrentPageId, useIsEnabledAttachTitleHeader, useTemplateBodyData,
-  useIsEditable, useIsIndentSizeForced, useIsUploadableFile, useIsUploadableImage, useEditingMarkdown, useIsNotFound,
+  useIsEditable, useIsUploadableFile, useIsUploadableImage, useIsNotFound, useIsIndentSizeForced,
 } from '~/stores/context';
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
   useIsEnabledUnsavedWarning,
+  useEditingMarkdown,
 } from '~/stores/editor';
 import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
 import { usePreviewOptions } from '~/stores/renderer';
 import {
   EditorMode,
-  useEditorMode, useIsMobile, useSelectedGrant,
+  useEditorMode, useSelectedGrant,
 } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
@@ -43,7 +44,10 @@ import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
 const logger = loggerFactory('growi:PageEditor');
 
 
-declare const globalEmitter: EventEmitter;
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
 
 
 // for scrolling
@@ -68,17 +72,17 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: templateBodyData } = useTemplateBodyData();
   const { data: isEditable } = useIsEditable();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
-  const { data: isMobile } = useIsMobile();
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
-  const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
-  const { data: isEnabledUnsavedWarning, mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+  const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableImage } = useIsUploadableImage();
 
   const { data: rendererOptions } = usePreviewOptions();
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+  const saveOrUpdate = useSaveOrUpdate();
 
   const currentRevisionId = currentPage?.revision?._id;
 
@@ -106,20 +110,6 @@ const PageEditor = React.memo((): JSX.Element => {
   const editorRef = useRef<IEditorMethods>(null);
   const previewRef = useRef<HTMLDivElement>(null);
 
-
-  // const optionsToSave = useMemo(() => {
-  //   if (grantData == null) {
-  //     return;
-  //   }
-  //   const slackChannels = slackChannelsData ? slackChannelsData.toString() : '';
-  //   const optionsToSave = getOptionsToSave(
-  //     isSlackEnabled ?? false, slackChannels,
-  //     grantData.grant, grantData.grantedGroup?.id, grantData.grantedGroup?.name,
-  //     pageTags || [],
-  //   );
-  //   return optionsToSave;
-  // }, [grantData, isSlackEnabled, pageTags, slackChannelsData]);
-
   const setMarkdownWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string, isClean: boolean) => {
     markdownToSave.current = value;
     setMarkdownToPreview(value);
@@ -143,16 +133,21 @@ const PageEditor = React.memo((): JSX.Element => {
     const grant = grantData.grant || PageGrant.GRANT_PUBLIC;
     const grantedGroup = grantData?.grantedGroup;
 
-    const optionsToSave = Object.assign(
-      getOptionsToSave(isSlackEnabled, slackChannels, grant || 1, grantedGroup?.id, grantedGroup?.name, pageTags || []),
-      { ...opts },
-    );
+    const optionsToSave: OptionsToSave = {
+      isSlackEnabled,
+      slackChannels,
+      grant: grant || 1,
+      pageTags: pageTags || [],
+      grantUserGroupId: grantedGroup?.id,
+      grantUserGroupName: grantedGroup?.name,
+      ...opts,
+    };
 
     try {
       const { page } = await saveOrUpdate(
-        optionsToSave,
-        { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId },
         markdownToSave.current,
+        { pageId, path: currentPagePath || currentPathname, revisionId: currentRevisionId },
+        optionsToSave,
       );
 
       return page;
@@ -172,7 +167,7 @@ const PageEditor = React.memo((): JSX.Element => {
     }
 
   // eslint-disable-next-line max-len
-  }, [grantData, isSlackEnabled, currentPathname, slackChannels, pageTags, pageId, currentPagePath, currentRevisionId]);
+  }, [grantData, isSlackEnabled, currentPathname, slackChannels, pageTags, saveOrUpdate, pageId, currentPagePath, currentRevisionId]);
 
   const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
     if (editorMode !== EditorMode.Editor) {
@@ -183,10 +178,7 @@ const PageEditor = React.memo((): JSX.Element => {
     if (page == null) {
       return;
     }
-    // The updateFn should be a promise or asynchronous function to handle the remote mutation
-    // it should return updated data. see: https://swr.vercel.app/docs/mutation#optimistic-updates
-    // Moreover, `async() => false` does not work since it's too fast to be calculated.
-    await mutateIsEnabledUnsavedWarning(new Promise(r => setTimeout(() => r(false), 10)), { optimisticData: () => false });
+
     if (isNotFound) {
       await router.push(`/${page._id}`);
     }
@@ -195,7 +187,7 @@ const PageEditor = React.memo((): JSX.Element => {
       await mutateCurrentPage();
     }
     mutateEditorMode(EditorMode.View);
-  }, [editorMode, save, mutateIsEnabledUnsavedWarning, isNotFound, mutateEditorMode, router, mutateCurrentPageId, mutateCurrentPage]);
+  }, [editorMode, save, isNotFound, mutateEditorMode, router, mutateCurrentPageId, mutateCurrentPage]);
 
   const saveWithShortcut = useCallback(async() => {
     if (editorMode !== EditorMode.Editor) {
@@ -411,33 +403,21 @@ const PageEditor = React.memo((): JSX.Element => {
     }
   }, [editorMode]);
 
-  // Unnecessary code. Delete after PageEditor and PageEditorByHackmd implementation has completed. -- 2022.09.06 Yuki Takei
-  //
-  // set handler to update editor value
-  // useEffect(() => {
-  //   const handler = (markdown) => {
-  //     if (editorRef.current != null) {
-  //       editorRef.current.setValue(markdown);
-  //     }
-  //   };
-  //   globalEmitter.on('updateEditorValue', handler);
-
-  //   return function cleanup() {
-  //     globalEmitter.removeListener('updateEditorValue', handler);
-  //   };
-  // }, []);
-
   // Detect indent size from contents (only when users are allowed to change it)
-  // useEffect(() => {
-  //   const currentPageMarkdown = pageContainer.state.markdown;
-  //   if (!isIndentSizeForced && currentPageMarkdown != null) {
-  //     const detectedIndent = detectIndent(currentPageMarkdown);
-  //     if (detectedIndent.type === 'space' && new Set([2, 4]).has(detectedIndent.amount)) {
-  //       mutateCurrentIndentSize(detectedIndent.amount);
-  //     }
-  //   }
-  // }, [isIndentSizeForced, mutateCurrentIndentSize, pageContainer.state.markdown]);
+  useEffect(() => {
+    // do nothing if the indent size fixed
+    if (isIndentSizeForced == null || isIndentSizeForced) {
+      return;
+    }
 
+    // detect from markdown
+    if (initialValue != null) {
+      const detectedIndent = detectIndent(initialValue);
+      if (detectedIndent.type === 'space' && new Set([2, 4]).has(detectedIndent.amount)) {
+        mutateCurrentIndentSize(detectedIndent.amount);
+      }
+    }
+  }, [initialValue, isIndentSizeForced, mutateCurrentIndentSize]);
 
   if (!isEditable) {
     return <></>;
@@ -458,7 +438,7 @@ const PageEditor = React.memo((): JSX.Element => {
           isUploadable={isUploadable}
           isUploadableFile={isUploadableFile}
           isTextlintEnabled={isTextlintEnabled}
-          indentSize={indentSize}
+          indentSize={currentIndentSize}
           onScroll={editorScrolledHandler}
           onScrollCursorIntoView={editorScrollCursorIntoViewHandler}
           onChange={markdownChangedHandler}

+ 34 - 21
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -131,8 +131,10 @@ class CodeMirrorEditor extends AbstractEditor {
     this.handleCtrlEnterKey = this.handleCtrlEnterKey.bind(this);
 
     this.scrollCursorIntoViewHandler = this.scrollCursorIntoViewHandler.bind(this);
+    this.scrollCursorIntoViewHandlerThrottled = throttle(500, this.scrollCursorIntoViewHandler);
     this.pasteHandler = this.pasteHandler.bind(this);
     this.cursorHandler = this.cursorHandler.bind(this);
+    this.cursorHandlerDebounced = debounce(200, throttle(500, this.cursorHandler));
     this.changeHandler = this.changeHandler.bind(this);
     this.turnOnEmojiPickerMode = this.turnOnEmojiPickerMode.bind(this);
     this.turnOffEmojiPickerMode = this.turnOffEmojiPickerMode.bind(this);
@@ -156,7 +158,8 @@ class CodeMirrorEditor extends AbstractEditor {
     this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
 
     this.foldDrawioSection = this.foldDrawioSection.bind(this);
-    this.onSaveForDrawio = this.onSaveForDrawio.bind(this);
+    this.clickDrawioIconHandler = this.clickDrawioIconHandler.bind(this);
+    this.clickTableIconHandler = this.clickTableIconHandler.bind(this);
 
   }
 
@@ -866,7 +869,7 @@ class CodeMirrorEditor extends AbstractEditor {
     this.linkEditModal.current.show(markdownLinkUtil.getMarkdownLink(this.getCodeMirror()));
   }
 
-  // fold draw.io section (::: drawio ~ :::)
+  // fold draw.io section (``` drawio ~ ```)
   foldDrawioSection() {
     const editor = this.getCodeMirror();
     const lineNumbers = mdu.findAllDrawioSection(editor);
@@ -875,13 +878,29 @@ class CodeMirrorEditor extends AbstractEditor {
     });
   }
 
-  onSaveForDrawio(drawioData) {
-    const range = mdu.replaceFocusedDrawioWithEditor(this.getCodeMirror(), drawioData);
-    // Fold the section after the drawio section (:::drawio) has been updated.
-    this.foldDrawioSection();
-    return range;
+  clickDrawioIconHandler() {
+    const drawioMxFile = mdu.getMarkdownDrawioMxfile(this.getCodeMirror());
+
+    this.props.onClickDrawioBtn(
+      drawioMxFile,
+      // onSave
+      (drawioMxFile) => {
+        mdu.replaceFocusedDrawioWithEditor(this.getCodeMirror(), drawioMxFile);
+        // Fold the section after the drawio section (```drawio) has been updated.
+        this.foldDrawioSection();
+      },
+    );
   }
 
+  clickTableIconHandler() {
+    const markdownTable = mtu.getMarkdownTable(this.getCodeMirror());
+
+    this.props.onClickTableBtn(
+      markdownTable,
+      this.getCodeMirror(),
+      this.props.editorSettings.autoFormatMarkdownTable,
+    );
+  }
 
   getNavbarItems() {
     return [
@@ -1008,13 +1027,7 @@ class CodeMirrorEditor extends AbstractEditor {
         color={null}
         size="sm"
         title="Table"
-        onClick={() => {
-          this.props.onClickTableBtn(
-            mtu.getMarkdownTable(this.getCodeMirror()),
-            this.getCodeMirror(),
-            this.props.editorSettings.autoFormatMarkdownTable,
-          );
-        }}
+        onClick={this.clickTableIconHandler}
       >
         <EditorIcon icon="Table" />
       </Button>,
@@ -1023,7 +1036,7 @@ class CodeMirrorEditor extends AbstractEditor {
         color={null}
         bssize="small"
         title="draw.io"
-        onClick={() => this.props.onClickDrawioBtn(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()))}
+        onClick={this.clickDrawioIconHandler}
       >
         <EditorIcon icon="Drawio" />
       </Button>,
@@ -1091,7 +1104,7 @@ class CodeMirrorEditor extends AbstractEditor {
             },
             lint,
           }}
-          onCursor={this.cursorHandler}
+          onCursor={this.cursorHandlerDebounced}
           onScroll={(editor, data) => {
             if (this.props.onScroll != null) {
             // add line data
@@ -1109,7 +1122,7 @@ class CodeMirrorEditor extends AbstractEditor {
           onKeyPress={this.keyPressHandler}
           onKeyDown={this.keyDownHandler}
           onPasteFiles={this.pasteHandler}
-          onScrollCursorIntoView={this.scrollCursorIntoViewHandler}
+          onScrollCursorIntoView={this.scrollCursorIntoViewHandlerThrottled}
         />
 
         { this.renderLoadingKeymapOverlay() }
@@ -1152,12 +1165,12 @@ const CodeMirrorEditorFc = React.forwardRef((props, ref) => {
   const { open: openDrawioModal } = useDrawioModal();
   const { open: openHandsontableModal } = useHandsontableModal();
 
-  const openDrawioModalHandler = useCallback((drawioMxFile) => {
-    openDrawioModal(drawioMxFile);
+  const openDrawioModalHandler = useCallback((drawioMxFile, onSave) => {
+    openDrawioModal(drawioMxFile, onSave);
   }, [openDrawioModal]);
 
-  const openTableModalHandler = useCallback((table, editor, autoFormatMarkdownTable) => {
-    openHandsontableModal(table, editor, autoFormatMarkdownTable);
+  const openTableModalHandler = useCallback((markdownTable, editor, autoFormatMarkdownTable) => {
+    openHandsontableModal(markdownTable, editor, autoFormatMarkdownTable);
   }, [openHandsontableModal]);
 
   return (

+ 1 - 1
packages/app/src/components/PageEditor/ConflictDiffModal.tsx

@@ -11,7 +11,7 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import type { OptionsToSave } from '~/interfaces/editor-settings';
+import { OptionsToSave } from '~/interfaces/page-operation';
 import { useCurrentUser } from '~/stores/context';
 import { useEditorMode } from '~/stores/ui';
 

+ 18 - 5
packages/app/src/components/PageEditor/DrawioCommunicationHelper.ts

@@ -1,3 +1,5 @@
+import { extractCodeFromMxfile } from '@growi/remark-drawio-plugin';
+
 import loggerFactory from '~/utils/logger';
 
 
@@ -41,11 +43,7 @@ export class DrawioCommunicationHelper {
       }
     }
 
-    if (event.data === 'ready') {
-      event.source?.postMessage(drawioMxFile, { targetOrigin: '*' });
-      return;
-    }
-
+    // configure
     if (event.data === '{"event":"configure"}') {
       if (event.source == null) {
         return;
@@ -63,6 +61,21 @@ export class DrawioCommunicationHelper {
       return;
     }
 
+    // restore diagram data
+    if (event.data === 'ready') {
+      let code = drawioMxFile;
+      try {
+        code = extractCodeFromMxfile(drawioMxFile);
+      }
+      // catch error if drawioMxFile is not XML
+      catch (err) {
+        // do nothing because drawioMxFile might be base64 code
+      }
+
+      event.source?.postMessage(code, { targetOrigin: '*' });
+      return;
+    }
+
     if (typeof event.data === 'string' && event.data.match(/mxfile/)) {
       if (event.data.length > 0) {
         const parser = new DOMParser();

+ 3 - 7
packages/app/src/components/PageEditor/DrawioModal.tsx

@@ -35,11 +35,7 @@ const drawioConfig = {
 };
 
 
-type Props = {
-  // onSave: (drawioData) => void,
-};
-
-export const DrawioModal = (props: Props): JSX.Element => {
+export const DrawioModal = (): JSX.Element => {
   const { data: drawioUri } = useDrawioUri();
   const { data: personalSettingsInfo } = usePersonalSettings();
 
@@ -71,9 +67,9 @@ export const DrawioModal = (props: Props): JSX.Element => {
     return new DrawioCommunicationHelper(
       drawioUri,
       drawioConfig,
-      { onClose: closeDrawioModal },
+      { onClose: closeDrawioModal, onSave: drawioModalData?.onSave },
     );
-  }, [closeDrawioModal, drawioUri]);
+  }, [closeDrawioModal, drawioModalData?.onSave, drawioUri]);
 
   const receiveMessageHandler = useCallback((event: MessageEvent) => {
     if (drawioModalData == null) {

+ 10 - 2
packages/app/src/components/PageEditor/HandsontableModal.tsx

@@ -32,10 +32,12 @@ export const HandsontableModal = (): JSX.Element => {
 
   const { t } = useTranslation('commons');
   const { data: handsontableModalData, close: closeHandsontableModal } = useHandsontableModal();
+
   const isOpened = handsontableModalData?.isOpened ?? false;
   const table = handsontableModalData?.table;
   const autoFormatMarkdownTable = handsontableModalData?.autoFormatMarkdownTable ?? false;
   const editor = handsontableModalData?.editor;
+  const onSave = handsontableModalData?.onSave;
 
   const defaultMarkdownTable = () => {
     return new MarkdownTable(
@@ -121,7 +123,7 @@ export const HandsontableModal = (): JSX.Element => {
   };
 
   const save = () => {
-    if (hotTable == null || editor == null) {
+    if (hotTable == null) {
       return;
     }
 
@@ -130,8 +132,14 @@ export const HandsontableModal = (): JSX.Element => {
       markdownTableOption.latest,
     ).normalizeCells();
 
-    mtu.replaceFocusedMarkdownTableWithEditor(editor, newMarkdownTable);
+    // onSave is passed only when editing table directly from the page.
+    if (onSave != null) {
+      onSave(newMarkdownTable);
+      cancel();
+      return;
+    }
 
+    mtu.replaceFocusedMarkdownTableWithEditor(editor, newMarkdownTable);
     cancel();
   };
 

+ 7 - 7
packages/app/src/components/PageEditor/MarkdownDrawioUtil.js

@@ -4,8 +4,8 @@
 class MarkdownDrawioUtil {
 
   constructor() {
-    this.lineBeginPartOfDrawioRE = /^:::(\s.*)drawio$/;
-    this.lineEndPartOfDrawioRE = /^:::$/;
+    this.lineBeginPartOfDrawioRE = /^```(\s.*)drawio$/;
+    this.lineEndPartOfDrawioRE = /^```$/;
   }
 
   /**
@@ -100,9 +100,9 @@ class MarkdownDrawioUtil {
       const bod = this.getBod(editor);
       const eod = this.getEod(editor);
 
-      // skip block begin sesion("::: drawio")
+      // skip block begin sesion("``` drawio")
       bod.line++;
-      // skip block end sesion(":::")
+      // skip block end sesion("```")
       eod.line--;
       eod.ch = editor.getDoc().getLine(eod.line).length;
 
@@ -113,7 +113,7 @@ class MarkdownDrawioUtil {
 
   replaceFocusedDrawioWithEditor(editor, drawioData) {
     const curPos = editor.getCursor();
-    const drawioBlock = ['::: drawio', drawioData.toString(), ':::'].join('\n');
+    const drawioBlock = ['``` drawio', drawioData.toString(), '```'].join('\n');
     let beginPos;
     let endPos;
 
@@ -145,9 +145,9 @@ class MarkdownDrawioUtil {
     if (markdownBeforeDrawio.length > 0) {
       newMarkdown += `${markdownBeforeDrawio.join('\n')}\n`;
     }
-    newMarkdown += '::: drawio\n';
+    newMarkdown += '``` drawio\n';
     newMarkdown += drawioData;
-    newMarkdown += '\n:::';
+    newMarkdown += '\n```';
     if (markdownAfterDrawio.length > 0) {
       newMarkdown += `\n${markdownAfterDrawio.join('\n')}`;
     }

+ 5 - 0
packages/app/src/components/PageEditor/MarkdownTableUtil.js

@@ -96,6 +96,11 @@ class MarkdownTableUtil {
     return MarkdownTable.fromMarkdownString(strFromBotToEot);
   }
 
+  getMarkdownTableFromLine(markdown, bol, eol) {
+    const tableLines = markdown.split(/\r\n|\r|\n/).slice(bol - 1, eol).join('\n');
+    return MarkdownTable.fromMarkdownString(tableLines);
+  }
+
   /**
    * return boolean value whether the cursor position is end of line
    */

+ 38 - 47
packages/app/src/components/PageEditorByHackmd.tsx

@@ -10,16 +10,16 @@ import { useRouter } from 'next/router';
 import { useTranslation } from 'react-i18next';
 import urljoin from 'url-join';
 
-import { saveOrUpdate } from '~/client/services/page-operation';
+import { useSaveOrUpdate } from '~/client/services/page-operation';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
-import { getOptionsToSave } from '~/client/util/editor';
 import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
+import { OptionsToSave } from '~/interfaces/page-operation';
 import {
   useCurrentPageId, useCurrentPathname, useHackmdUri, useIsNotFound,
 } from '~/stores/context';
 import {
-  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
+  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors,
 } from '~/stores/editor';
 import {
   usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useRemoteRevisionId,
@@ -35,7 +35,12 @@ import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
 
 const logger = loggerFactory('growi:PageEditorByHackmd');
 
-declare const globalEmitter: EventEmitter;
+
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
+
 
 type HackEditorRef = {
   getValue: () => Promise<string>
@@ -57,6 +62,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { data: grant } = useSelectedGrant();
   const { data: hackmdUri } = useHackmdUri();
+  const saveOrUpdate = useSaveOrUpdate();
 
   const { returnPathForURL } = pathUtils;
 
@@ -77,7 +83,6 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: pageIdOnHackmd, mutate: mutatePageIdOnHackmd } = usePageIdOnHackmd();
   const { data: hasDraftOnHackmd, mutate: mutateHasDraftOnHackmd } = useHasDraftOnHackmd();
   const { data: revisionIdHackmdSynced, mutate: mutateRevisionIdHackmdSynced } = useRevisionIdHackmdSynced();
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const [isHackmdDraftUpdatingInRealtime, setIsHackmdDraftUpdatingInRealtime] = useState(false);
   const { data: remoteRevisionId, mutate: mutateRemoteRevisionId } = useRemoteRevisionId(revision?._id);
 
@@ -91,34 +96,26 @@ export const PageEditorByHackmd = (): JSX.Element => {
         throw new Error('Some materials to save are invalid');
       }
 
-      let optionsToSave;
-
-      const currentOptionsToSave = getOptionsToSave(
-        isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
-      );
-
-      if (opts != null) {
-        optionsToSave = Object.assign(currentOptionsToSave, {
-          ...opts,
-        });
-      }
-      else {
-        optionsToSave = currentOptionsToSave;
-      }
+      const optionsToSave: OptionsToSave = {
+        isSlackEnabled,
+        slackChannels,
+        grant: grant.grant,
+        grantUserGroupId: grant.grantedGroup?.id,
+        grantUserGroupName: grant.grantedGroup?.name,
+        pageTags: pageTags ?? [],
+        isSyncRevisionToHackmd: true,
+        ...opts,
+      };
 
       const markdown = await hackmdEditorRef.current.getValue();
 
-      const { page } = await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
+      const { page } = await saveOrUpdate(markdown, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, optionsToSave);
       await mutatePageData();
       await mutateTagsInfo();
 
       if (page == null) {
         return;
       }
-      // The updateFn should be a promise or asynchronous function to handle the remote mutation
-      // it should return updated data. see: https://swr.vercel.app/docs/mutation#optimistic-updates
-      // Moreover, `async() => false` does not work since it's too fast to be calculated.
-      await mutateIsEnabledUnsavedWarning(new Promise(r => setTimeout(() => r(false), 10)), { optimisticData: () => false });
       if (isNotFound) {
         await router.push(`/${page._id}`);
       }
@@ -132,8 +129,8 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error('failed to save', error);
       toastError(error.message);
     }
-  }, [editorMode, isSlackEnabled, currentPathname, slackChannels, grant, revision, pageTags, pageId,
-      currentPagePath, mutatePageData, mutateTagsInfo, mutateIsEnabledUnsavedWarning, isNotFound, mutateEditorMode, router, mutateCurrentPageId]);
+  // eslint-disable-next-line max-len
+  }, [editorMode, isSlackEnabled, currentPathname, slackChannels, grant, revision, pageTags, saveOrUpdate, pageId, currentPagePath, mutatePageData, mutateTagsInfo, isNotFound, mutateEditorMode, router, mutateCurrentPageId]);
 
   // set handler to save and reload Page
   useEffect(() => {
@@ -226,7 +223,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error(err);
       toastError(err.message);
     }
-  }, [setIsHackmdDraftUpdatingInRealtime, mutateHasDraftOnHackmd, mutatePageIdOnHackmd, mutateRevisionIdHackmdSynced, pageId]);
+  }, [pageId, mutateHasDraftOnHackmd, mutatePageIdOnHackmd, mutateRemoteRevisionId, mutateRevisionIdHackmdSynced]);
 
   /**
    * save and update state of containers
@@ -239,10 +236,16 @@ export const PageEditorByHackmd = (): JSX.Element => {
         isSlackEnabled == null || grant == null || slackChannels == null || pageId == null
         || revisionIdHackmdSynced == null || currentPagePathOrPathname == null
       ) { throw new Error('Some materials to save are invalid') }
-      const optionsToSave = getOptionsToSave(
-        isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
-      );
-      const res = await saveOrUpdate(optionsToSave, { pageId, path: currentPagePathOrPathname, revisionId: revisionIdHackmdSynced }, markdown);
+      const optionsToSave = {
+        isSlackEnabled,
+        slackChannels,
+        grant: grant.grant,
+        grantUserGroupId: grant.grantedGroup?.id,
+        grantUserGroupName: grant.grantedGroup?.name,
+        pageTags: pageTags ?? [],
+        isSyncRevisionToHackmd: true,
+      };
+      const res = await saveOrUpdate(markdown, { pageId, path: currentPagePathOrPathname, revisionId: revisionIdHackmdSynced }, optionsToSave);
 
       // update pageData
       mutatePageData(res);
@@ -252,7 +255,6 @@ export const PageEditorByHackmd = (): JSX.Element => {
       mutateRevisionIdHackmdSynced(res.page.revisionHackmdSynced);
       mutateHasDraftOnHackmd(res.page.hasDraftOnHackmd);
       mutateTagsInfo();
-      mutateIsEnabledUnsavedWarning(false);
 
       logger.debug('success to save');
 
@@ -262,20 +264,8 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error('failed to save', error);
       toastError(error.message);
     }
-  }, [isSlackEnabled,
-      grant,
-      slackChannels,
-      pageId,
-      revisionIdHackmdSynced,
-      currentPathname,
-      pageTags,
-      currentPagePath,
-      mutatePageData,
-      mutateRevisionIdHackmdSynced,
-      mutateHasDraftOnHackmd,
-      mutateTagsInfo,
-      mutateIsEnabledUnsavedWarning,
-      t]);
+  // eslint-disable-next-line max-len
+  }, [currentPagePath, currentPathname, isSlackEnabled, grant, slackChannels, pageId, revisionIdHackmdSynced, pageTags, saveOrUpdate, mutatePageData, mutateRemoteRevisionId, mutateRevisionIdHackmdSynced, mutateHasDraftOnHackmd, mutateTagsInfo, t]);
 
   /**
    * onChange event of HackmdEditor handler
@@ -433,7 +423,8 @@ export const PageEditorByHackmd = (): JSX.Element => {
         {content}
       </div>
     );
-  }, [discardChanges, isInitializing, isResume, resumeToEdit, startToEdit, t, hackmdUri, pageId, remoteRevisionId, revisionIdHackmdSynced, revision?._id]);
+  // eslint-disable-next-line max-len
+  }, [pageId, hackmdUri, isResume, t, revisionIdHackmdSynced, remoteRevisionId, pageData, returnPathForURL, isInitializing, resumeToEdit, discardChanges, revision?._id, startToEdit]);
 
   if (editorMode == null || revision == null) {
     return <></>;

+ 12 - 24
packages/app/src/components/PageRenameModal.tsx

@@ -50,7 +50,6 @@ const PageRenameModal = (): JSX.Element => {
 
   const [subordinatedPages, setSubordinatedPages] = useState([]);
   const [existingPaths, setExistingPaths] = useState<string[]>([]);
-  const [canRename, setCanRename] = useState(false);
   const [isRenameRecursively, setIsRenameRecursively] = useState(true);
   const [isRenameRedirect, setIsRenameRedirect] = useState(false);
   const [isRemainMetadata, setIsRemainMetadata] = useState(false);
@@ -81,6 +80,16 @@ const PageRenameModal = (): JSX.Element => {
     }
   }, [isOpened, page, updateSubordinatedList]);
 
+  const canRename = useMemo(() => {
+    if (page == null || isMatchedWithUserHomePagePath || page.data.path === pageNameInput) {
+      return false;
+    }
+    if (isV5Compatible(page.meta)) {
+      return existingPaths.length === 0; // v5 data
+    }
+    return isRenameRecursively; // v4 data
+  }, [existingPaths.length, isMatchedWithUserHomePagePath, isRenameRecursively, page, pageNameInput]);
+
   const rename = useCallback(async() => {
     if (page == null || !canRename) {
       return;
@@ -127,9 +136,6 @@ const PageRenameModal = (): JSX.Element => {
     try {
       const res = await apiv3Get<{ existPaths: string[]}>('/page/exist-paths', { fromPath, toPath });
       const { existPaths } = res.data;
-      if (existPaths.length === 0) {
-        setCanRename(true);
-      }
       setExistingPaths(existPaths);
     }
     catch (err) {
@@ -157,13 +163,6 @@ const PageRenameModal = (): JSX.Element => {
     }
   }, [isOpened, pageNameInput, subordinatedPages, checkExistPathsDebounce, page, checkIsUsersHomePageDebounce]);
 
-  useEffect(() => {
-    if (isOpened && page != null) {
-      setCanRename(false);
-    }
-  }, [isOpened, page, pageNameInput]);
-
-
   function ppacInputChangeHandler(value) {
     setErrs(null);
     setPageNameInput(value);
@@ -330,24 +329,13 @@ const PageRenameModal = (): JSX.Element => {
       return <></>;
     }
 
-    let submitButtonDisabled = false;
+    const submitButtonDisabled = !canRename;
 
-    if (isMatchedWithUserHomePagePath) {
-      submitButtonDisabled = true;
-    }
-    else if (!canRename) {
-      submitButtonDisabled = true;
-    }
-    else if (isV5Compatible(page.meta)) {
-      submitButtonDisabled = existingPaths.length !== 0; // v5 data
-    }
-    else {
-      submitButtonDisabled = !isRenameRecursively; // v4 data
-    }
     return (
       <>
         <ApiErrorMessageList errs={errs} targetPath={pageNameInput} />
         <button
+          data-testid="grw-page-rename-button"
           type="button"
           className="btn btn-primary"
           onClick={rename}

+ 19 - 0
packages/app/src/components/ReactMarkdownComponents/DrawioViewerWithEditButton.module.scss

@@ -0,0 +1,19 @@
+.drawio-viewer-with-edit-button :global {
+  position: relative;
+
+  .btn-edit-drawio {
+    position: absolute;
+    top: 11px;
+    right: 10px;
+    z-index: 14;
+    font-size: 12px;
+    line-height: 1;
+    opacity: 0;
+  }
+}
+
+.drawio-viewer-with-edit-button:hover :global {
+  .btn-edit-drawio {
+    opacity: 1;
+  }
+}

+ 70 - 0
packages/app/src/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx

@@ -0,0 +1,70 @@
+import React, { useCallback, useState } from 'react';
+
+import EventEmitter from 'events';
+
+import {
+  DrawioEditByViewerProps,
+  DrawioViewer, DrawioViewerProps, extractCodeFromMxfile,
+} from '@growi/remark-drawio-plugin';
+import { useTranslation } from 'next-i18next';
+
+import { useIsGuestUser, useIsSharedUser, useShareLinkId } from '~/stores/context';
+
+import styles from './DrawioViewerWithEditButton.module.scss';
+
+
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
+
+
+export const DrawioViewerWithEditButton = React.memo((props: DrawioViewerProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { bol, eol } = props;
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: shareLinkId } = useShareLinkId();
+
+  const [isRendered, setRendered] = useState(false);
+  const [mxfile, setMxfile] = useState('');
+
+  const editButtonClickHandler = useCallback(() => {
+    const data: DrawioEditByViewerProps = {
+      bol, eol, drawioMxFile: extractCodeFromMxfile(mxfile),
+    };
+    globalEmitter.emit('launchDrawioModal', data);
+  }, [bol, eol, mxfile]);
+
+  const renderingStartHandler = useCallback(() => {
+    setRendered(false);
+  }, []);
+
+  const renderingUpdatedHandler = useCallback((mxfile: string | null) => {
+    setRendered(mxfile != null);
+
+    if (mxfile != null) {
+      setMxfile(mxfile);
+    }
+  }, []);
+
+  const showEditButton = isRendered && !isGuestUser && !isSharedUser && shareLinkId == null;
+
+  return (
+    <div className={`drawio-viewer-with-edit-button ${styles['drawio-viewer-with-edit-button']}`}>
+      { showEditButton && (
+        <button
+          type="button"
+          className="btn btn-outline-secondary btn-edit-drawio"
+          onClick={editButtonClickHandler}
+        >
+          <i className="icon-note mr-1"></i>{t('Edit')}
+        </button>
+      ) }
+      <DrawioViewer {...props} onRenderingStart={renderingStartHandler} onRenderingUpdated={renderingUpdatedHandler} />
+    </div>
+  );
+});
+DrawioViewerWithEditButton.displayName = 'DrawioViewerWithEditButton';

+ 16 - 3
packages/app/src/components/ReactMarkdownComponents/Header.tsx

@@ -5,13 +5,18 @@ import EventEmitter from 'events';
 import { useRouter } from 'next/router';
 import { Element } from 'react-markdown/lib/rehype-filter';
 
-import { NextLink } from './NextLink';
+import { useIsGuestUser, useIsSharedUser, useShareLinkId } from '~/stores/context';
 
+import { NextLink } from './NextLink';
 
 import styles from './Header.module.scss';
 
 
-declare const globalEmitter: EventEmitter;
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
+
 
 function setCaretLine(line?: number): void {
   if (line != null) {
@@ -51,6 +56,10 @@ export const Header = (props: HeaderProps): JSX.Element => {
     node, id, children, level,
   } = props;
 
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: shareLinkId } = useShareLinkId();
+
   const router = useRouter();
 
   const [isActive, setActive] = useState(false);
@@ -76,13 +85,17 @@ export const Header = (props: HeaderProps): JSX.Element => {
     };
   }, [activateByHash, router.events]);
 
+  const showEditButton = !isGuestUser && !isSharedUser && shareLinkId == null;
+
   return (
     <CustomTag id={id} className={`revision-head ${styles['revision-head']} ${isActive ? 'blink' : ''}`}>
       {children}
       <NextLink href={`#${id}`} className="revision-head-link">
         <span className="icon-link"></span>
       </NextLink>
-      <EditLink line={node.position?.start.line} />
+      {showEditButton && (
+        <EditLink line={node.position?.start.line} />
+      )}
     </CustomTag>
   );
 };

+ 25 - 0
packages/app/src/components/ReactMarkdownComponents/TableWithEditButton.module.scss

@@ -0,0 +1,25 @@
+/**
+ * for table with handsontable modal button
+ */
+.editable-with-handsontable :global {
+  position: relative;
+
+  .handsontable-modal-trigger {
+    position: absolute;
+    top: 11px;
+    right: 10px;
+    padding: 0;
+    font-size: 16px;
+    line-height: 1;
+    vertical-align: bottom;
+    background-color: transparent;
+    border: none;
+    opacity: 0;
+  }
+}
+
+.editable-with-handsontable:hover :global {
+  .handsontable-modal-trigger {
+    opacity: 1;
+  }
+}

+ 52 - 0
packages/app/src/components/ReactMarkdownComponents/TableWithEditButton.tsx

@@ -0,0 +1,52 @@
+import React, { useCallback } from 'react';
+
+import EventEmitter from 'events';
+
+import { Element } from 'react-markdown/lib/rehype-filter';
+
+import { useIsGuestUser, useIsSharedUser, useShareLinkId } from '~/stores/context';
+
+import styles from './TableWithEditButton.module.scss';
+
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
+
+type TableWithEditButtonProps = {
+  children: React.ReactNode,
+  node: Element,
+  className?: string
+}
+
+export const TableWithEditButton = React.memo((props: TableWithEditButtonProps): JSX.Element => {
+
+  const { children, node, className } = props;
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const { data: isSharedUser } = useIsSharedUser();
+  const { data: shareLinkId } = useShareLinkId();
+
+  const bol = node.position?.start.line;
+  const eol = node.position?.end.line;
+
+  const editButtonClickHandler = useCallback(() => {
+    globalEmitter.emit('launchHandsonTableModal', bol, eol);
+  }, [bol, eol]);
+
+  const showEditButton = !isGuestUser && !isSharedUser && shareLinkId == null;
+
+  return (
+    <div className={`editable-with-handsontable ${styles['editable-with-handsontable']}`}>
+      { showEditButton && (
+        <button className="handsontable-modal-trigger" onClick={editButtonClickHandler}>
+          <i className="icon-note"></i>
+        </button>
+      )}
+      <table className={`${className}`}>
+        {children}
+      </table>
+    </div>
+  );
+});
+TableWithEditButton.displayName = 'TableWithEditButton';

+ 7 - 2
packages/app/src/components/SavePageControls.tsx

@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
 
 import EventEmitter from 'events';
 
-import { pagePathUtils, PageGrant } from '@growi/core';
+import { pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import {
   UncontrolledButtonDropdown, Button,
@@ -19,7 +19,12 @@ import loggerFactory from '~/utils/logger';
 
 import GrantSelector from './SavePageControls/GrantSelector';
 
-declare const globalEmitter: EventEmitter;
+
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
+
 
 const logger = loggerFactory('growi:SavePageControls');
 

+ 38 - 0
packages/app/src/components/Script/DrawioViewerScript.tsx

@@ -0,0 +1,38 @@
+import { useCallback } from 'react';
+
+import type { IGraphViewerGlobal } from '@growi/remark-drawio-plugin';
+import Script from 'next/script';
+
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var GraphViewer: IGraphViewerGlobal;
+}
+
+export const DrawioViewerScript = (): JSX.Element => {
+  const loadedHandler = useCallback(() => {
+    // disable useResizeSensor and checkVisibleState
+    //   for preventing resize event by viewer.min.js
+    GraphViewer.useResizeSensor = false;
+    GraphViewer.prototype.checkVisibleState = false;
+
+    // Set responsive option.
+    // refs: https://github.com/jgraph/drawio/blob/v13.9.1/src/main/webapp/js/diagramly/GraphViewer.js#L89-L95
+    // GraphViewer.prototype.responsive = true;
+
+    // Set z-index ($zindex-dropdown + 200) for lightbox.
+    // 'lightbox' is like a modal dialog that appears when click on a drawio diagram.
+    // z-index refs: https://github.com/twbs/bootstrap/blob/v4.6.2/scss/_variables.scss#L681
+    GraphViewer.prototype.lightboxZIndex = 1200;
+    GraphViewer.prototype.toolbarZIndex = 1200;
+
+    GraphViewer.processElements();
+  }, []);
+
+  return (
+    <Script
+      type="text/javascript"
+      src="https://www.draw.io/js/viewer.min.js"
+      onLoad={loadedHandler}
+    />
+  );
+};

+ 1 - 1
packages/app/src/components/SubscribeButton.tsx

@@ -42,7 +42,7 @@ const SubscribeButton: FC<Props> = (props: Props) => {
         <i className={`fa ${isSubscribing ? 'fa-bell' : 'fa-bell-slash-o'}`}></i>
       </button>
 
-      <UncontrolledTooltip placement="top" target="subscribe-button" fade={false}>
+      <UncontrolledTooltip data-testid="subscribe-button-tooltip" placement="top" target="subscribe-button" fade={false}>
         {t(getTooltipMessage())}
       </UncontrolledTooltip>
     </>

+ 1 - 1
packages/app/src/components/User/SeenUserInfo.tsx

@@ -41,7 +41,7 @@ const SeenUserInfo: FC<Props> = (props: Props) => {
           </div>
         </PopoverBody>
       </Popover>
-      <UncontrolledTooltip placement="top" target="btn-seen-user" fade={false}>
+      <UncontrolledTooltip data-testid="seen-user-info-tooltip" placement="top" target="btn-seen-user" fade={false}>
         {t('tooltip.footprints')}
       </UncontrolledTooltip>
     </div>

+ 0 - 10
packages/app/src/interfaces/editor-settings.ts

@@ -35,13 +35,3 @@ export type EditorConfig = {
     isUploadableImage: boolean,
   }
 }
-
-export type OptionsToSave = {
-  isSlackEnabled: boolean;
-  slackChannels: string;
-  grant: number;
-  pageTags: string[] | null;
-  grantUserGroupId?: string | null;
-  grantUserGroupName?: string | null;
-  isSyncRevisionToHackmd?: boolean;
-};

+ 0 - 8
packages/app/src/interfaces/global.ts

@@ -1,8 +0,0 @@
-import EventEmitter from 'events';
-
-import { IGraphViewer } from './graph-viewer';
-
-export type CustomWindow = Window
-                         & typeof globalThis
-                         & { globalEmitter: EventEmitter }
-                         & { GraphViewer: IGraphViewer };

+ 0 - 11
packages/app/src/interfaces/graph-viewer.ts

@@ -1,11 +0,0 @@
-export interface IGraphViewer {
-  createViewerForElement: (Element) => void,
-}
-
-export const isGraphViewer = (val: any): val is IGraphViewer => {
-  if (typeof val === 'function' && typeof val.createViewerForElement === 'function') {
-    return true;
-  }
-
-  return false;
-};

+ 10 - 0
packages/app/src/interfaces/page-operation.ts

@@ -26,3 +26,13 @@ export type IPageOperationProcessData = {
 export type IPageOperationProcessInfo = {
   [pageId: string]: IPageOperationProcessData,
 }
+
+export type OptionsToSave = {
+  isSlackEnabled: boolean;
+  slackChannels: string;
+  grant: number;
+  pageTags: string[] | null;
+  grantUserGroupId?: string | null;
+  grantUserGroupName?: string | null;
+  isSyncRevisionToHackmd?: boolean;
+};

+ 6 - 0
packages/app/src/interfaces/rehype.ts

@@ -0,0 +1,6 @@
+export const RehypeSanitizeOption = {
+  RECOMMENDED: 'Recommended',
+  CUSTOM: 'Custom',
+} as const;
+
+export type RehypeSanitizeOption = typeof RehypeSanitizeOption[keyof typeof RehypeSanitizeOption];

+ 17 - 7
packages/app/src/pages/[[...path]].page.tsx

@@ -23,23 +23,23 @@ import { Comments } from '~/components/Comments';
 import { PageAlerts } from '~/components/PageAlert/PageAlerts';
 // import { useTranslation } from '~/i18n';
 import { CurrentPageContentFooter } from '~/components/PageContentFooter';
+import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { UsersHomePageFooterProps } from '~/components/UsersHomePageFooter';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 // import { renderScriptTagByName, renderHighlightJsStyleTag } from '~/service/cdn-resources-loader';
 // import { useRendererSettings } from '~/stores/renderer';
 // import { EditorMode, useEditorMode, useIsMobile } from '~/stores/ui';
 import type { EditorConfig } from '~/interfaces/editor-settings';
-import type { CustomWindow } from '~/interfaces/global';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { PageModel, PageDocument } from '~/server/models/page';
 import type { PageRedirectModel } from '~/server/models/page-redirect';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
-import { useSWRxCurrentPage, useSWRxIsGrantNormalized, useSWRxPageInfo } from '~/stores/page';
+import { useEditingMarkdown } from '~/stores/editor';
+import { useSWRxCurrentPage, useSWRxIsGrantNormalized } from '~/stores/page';
 import { useRedirectFrom } from '~/stores/page-redirect';
 import {
-  EditorMode,
   useEditorMode, useSelectedGrant,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
 } from '~/stores/ui';
@@ -64,7 +64,7 @@ import {
   useDrawioUri, useHackmdUri, useDefaultIndentSize, useIsIndentSizeForced,
   useIsAclEnabled, useIsSearchPage, useTemplateTagData, useTemplateBodyData, useIsEnabledAttachTitleHeader,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
-  useIsSlackConfigured, useRendererConfig, useEditingMarkdown,
+  useIsSlackConfigured, useRendererConfig,
   useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useCustomizedLogoSrc, useIsContainerFluid,
 } from '../stores/context';
 
@@ -74,6 +74,12 @@ import {
 // import { useCurrentPageSWR } from '../stores/page';
 
 
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var globalEmitter: EventEmitter;
+}
+
+
 const NotCreatablePage = dynamic(() => import('../components/NotCreatablePage').then(mod => mod.NotCreatablePage), { ssr: false });
 const ForbiddenPage = dynamic(() => import('../components/ForbiddenPage'), { ssr: false });
 const UnsavedAlertDialog = dynamic(() => import('../components/UnsavedAlertDialog'), { ssr: false });
@@ -154,7 +160,7 @@ type Props = CommonProps & {
   // isMailerSetup: boolean,
   isAclEnabled: boolean,
   // hasSlackConfig: boolean,
-  drawioUri: string,
+  drawioUri: string | null,
   hackmdUri: string,
   noCdn: string,
   // highlightJsStyle: string,
@@ -184,8 +190,8 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   const { data: currentUser } = useCurrentUser(props.currentUser ?? null);
 
   // register global EventEmitter
-  if (isClient()) {
-    (window as CustomWindow).globalEmitter = new EventEmitter();
+  if (isClient() && window.globalEmitter == null) {
+    window.globalEmitter = new EventEmitter();
   }
 
   // commons
@@ -299,7 +305,11 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
         {renderHighlightJsStyleTag(props.highlightJsStyle)}
         */}
       </Head>
+
+      <DrawioViewerScript />
+
       <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')} expandContainer={isContainerFluid}>
+
         <div className="h-100 d-flex flex-column justify-content-between">
           <header className="py-0 position-relative">
             <div id="grw-subnav-container">

+ 13 - 3
packages/app/src/pages/_document.page.tsx

@@ -6,25 +6,35 @@ import Document, {
   Html, Head, Main, NextScript,
 } from 'next/document';
 
+import { CrowiRequest } from '~/interfaces/crowi-request';
+
 
 // type GrowiDocumentProps = {};
 // declare type GrowiDocumentInitialProps = GrowiDocumentProps & DocumentInitialProps;
-declare type GrowiDocumentInitialProps = DocumentInitialProps;
+declare type GrowiDocumentInitialProps = DocumentInitialProps & { customCss: string };
 
 
-class GrowiDocument extends Document {
+class GrowiDocument extends Document<GrowiDocumentInitialProps> {
 
   static override async getInitialProps(ctx: DocumentContext): Promise<GrowiDocumentInitialProps> {
     const initialProps: DocumentInitialProps = await Document.getInitialProps(ctx);
+    const { crowi } = ctx.req as CrowiRequest<any>;
+    const { customizeService } = crowi;
+    const customCss: string = customizeService.getCustomCss();
 
-    return initialProps;
+    const props = { ...initialProps, customCss };
+    return props;
   }
 
   override render(): JSX.Element {
+    const { customCss } = this.props;
 
     return (
       <Html>
         <Head>
+          <style>
+            {customCss}
+          </style>
           {/*
           {renderScriptTagsByGroup('basis')}
           {renderStyleTagsByGroup('basis')}

+ 10 - 1
packages/app/src/pages/_private-legacy-pages.page.tsx

@@ -5,6 +5,7 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 import Head from 'next/head';
 
+import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
@@ -12,7 +13,7 @@ import type { IUser, IUserHasId } from '~/interfaces/user';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
 import {
-  useCsrfToken, useCurrentUser, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
+  useCsrfToken, useCurrentUser, useDrawioUri, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig,
 } from '~/stores/context';
 import {
@@ -33,6 +34,8 @@ type Props = CommonProps & {
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
 
+  drawioUri: string | null,
+
   // UI
   userUISettings?: IUserUISettings
   // Sidebar
@@ -59,6 +62,8 @@ const PrivateLegacyPage: NextPage<Props> = (props: Props) => {
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
 
+  useDrawioUri(props.drawioUri);
+
   // UserUISettings
   usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser ?? props.sidebarConfig.isSidebarDrawerMode);
   usePreferDrawerModeOnEditByUser(userUISettings?.preferDrawerModeOnEditByUser);
@@ -78,6 +83,8 @@ const PrivateLegacyPage: NextPage<Props> = (props: Props) => {
         */}
       </Head>
 
+      <DrawioViewerScript />
+
       <SearchResultLayout title={useCustomTitle(props, 'GROWI')}>
         <div id="private-regacy-pages">
           <PrivateLegacyPages />
@@ -109,6 +116,8 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
 
+  props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
+
   props.sidebarConfig = {
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),

+ 10 - 1
packages/app/src/pages/_search.page.tsx

@@ -5,6 +5,7 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 import Head from 'next/head';
 
+import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
@@ -12,7 +13,7 @@ import type { IUser, IUserHasId } from '~/interfaces/user';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
 import {
-  useCsrfToken, useCurrentUser, useIsContainerFluid, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
+  useCsrfToken, useCurrentUser, useDrawioUri, useIsContainerFluid, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useShowPageLimitationL,
 } from '~/stores/context';
 import {
@@ -35,6 +36,8 @@ type Props = CommonProps & {
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
 
+  drawioUri: string | null,
+
   // UI
   userUISettings?: IUserUISettings
   // Sidebar
@@ -64,6 +67,8 @@ const SearchResultPage: NextPage<Props> = (props: Props) => {
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
 
+  useDrawioUri(props.drawioUri);
+
   // UserUISettings
   usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser ?? props.sidebarConfig.isSidebarDrawerMode);
   usePreferDrawerModeOnEditByUser(userUISettings?.preferDrawerModeOnEditByUser);
@@ -96,6 +101,8 @@ const SearchResultPage: NextPage<Props> = (props: Props) => {
         */}
       </Head>
 
+      <DrawioViewerScript />
+
       <SearchResultLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
         <div id="search-page">
           <SearchPage />
@@ -130,6 +137,8 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
   props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');
 
+  props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
+
   props.sidebarConfig = {
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),

+ 98 - 87
packages/app/src/pages/share/[[...path]].page.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 
-import { IUser, IUserHasId } from '@growi/core';
+import { IUserHasId } from '@growi/core';
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
@@ -14,6 +14,7 @@ import { ShareLinkLayout } from '~/components/Layout/ShareLinkLayout';
 import GrowiContextualSubNavigation from '~/components/Navbar/GrowiContextualSubNavigation';
 import { Page } from '~/components/Page';
 import styles from '~/components/Page/DisplaySwitcher.module.scss'; // for PageList toc style
+import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import TableOfContents from '~/components/TableOfContents';
 import { SupportedAction, SupportedActionType } from '~/interfaces/activity';
 import { CrowiRequest } from '~/interfaces/crowi-request';
@@ -21,7 +22,7 @@ import { RendererConfig } from '~/interfaces/services/renderer';
 import { IShareLinkHasId } from '~/interfaces/share-link';
 import {
   useCurrentUser, useCurrentPathname, useCurrentPageId, useRendererConfig, useIsSearchPage,
-  useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault,
+  useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useDrawioUri,
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
@@ -42,6 +43,7 @@ type Props = CommonProps & {
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
+  drawioUri: string | null,
   rendererConfig: RendererConfig,
 };
 
@@ -55,6 +57,8 @@ const SharedPage: NextPage<Props> = (props: Props) => {
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
+  useDrawioUri(props.drawioUri);
+
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
   const { t } = useTranslation();
 
@@ -63,113 +67,120 @@ const SharedPage: NextPage<Props> = (props: Props) => {
   const shareLink = props.shareLink;
 
   return (
-    <ShareLinkLayout title={useCustomTitle(props, 'GROWI')} expandContainer={props.isContainerFluid}>
-      <div className="h-100 d-flex flex-column justify-content-between">
-        <header className="py-0 position-relative">
-          {isShowSharedPage && <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />}
-        </header>
-
-        <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
-
-        <div className="flex-grow-1">
-          <div id="content-main" className="content-main">
-            <div className="grw-container-convertible">
-              { props.disableLinkSharing && (
-                <div className="mt-4">
-                  <ForbiddenPage isLinkSharingDisabled={props.disableLinkSharing} />
-                </div>
-              )}
-
-              { (isNotFound && !props.disableLinkSharing) && (
-                <div className="container-lg">
-                  <h2 className="text-muted mt-4">
-                    <i className="icon-ban" aria-hidden="true" />
-                    <span> Page is not found</span>
-                  </h2>
-                </div>
-              )}
-
-              { (props.isExpired && !props.disableLinkSharing && shareLink != null) && (
-                <div className="container-lg">
-                  <ShareLinkAlert expiredAt={shareLink.expiredAt} createdAt={shareLink.createdAt} />
-                  <h2 className="text-muted mt-4">
-                    <i className="icon-ban" aria-hidden="true" />
-                    <span> Page is expired</span>
-                  </h2>
-                </div>
-              )}
-
-              {(isShowSharedPage && shareLink != null) && (
-                <>
-                  <ShareLinkAlert expiredAt={shareLink.expiredAt} createdAt={shareLink.createdAt} />
-                  <div className="d-flex flex-column flex-lg-row-reverse">
-
-                    <div className="grw-side-contents-container">
-                      <div className="grw-side-contents-sticky-container">
-
-                        {/* Page list */}
-                        <div className={`grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
-                          { shareLink.relatedPage.path != null && (
-                            <button
-                              type="button"
-                              className="btn btn-block btn-outline-secondary grw-btn-page-accessories
-                              rounded-pill d-flex justify-content-between align-items-center"
-                              onClick={() => openDescendantPageListModal(shareLink.relatedPage.path)}
-                              data-testid="pageListButton"
-                            >
-                              <div className="grw-page-accessories-control-icon">
-                                <PageListIcon />
-                              </div>
-                              {t('page_list')}
-                              <CountBadge count={shareLink.relatedPage.descendantCount} offset={1} />
-                            </button>
-                          ) }
-                        </div>
-
-                        <div className="d-none d-lg-block">
-                          <TableOfContents />
+    <>
+      <DrawioViewerScript />
+
+      <ShareLinkLayout title={useCustomTitle(props, 'GROWI')} expandContainer={props.isContainerFluid}>
+        <div className="h-100 d-flex flex-column justify-content-between">
+          <header className="py-0 position-relative">
+            {isShowSharedPage && <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />}
+          </header>
+
+          <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
+
+          <div className="flex-grow-1">
+            <div id="content-main" className="content-main">
+              <div className="grw-container-convertible">
+                { props.disableLinkSharing && (
+                  <div className="mt-4">
+                    <ForbiddenPage isLinkSharingDisabled={props.disableLinkSharing} />
+                  </div>
+                )}
+
+                { (isNotFound && !props.disableLinkSharing) && (
+                  <div className="container-lg">
+                    <h2 className="text-muted mt-4">
+                      <i className="icon-ban" aria-hidden="true" />
+                      <span> Page is not found</span>
+                    </h2>
+                  </div>
+                )}
+
+                { (props.isExpired && !props.disableLinkSharing && shareLink != null) && (
+                  <div className="container-lg">
+                    <ShareLinkAlert expiredAt={shareLink.expiredAt} createdAt={shareLink.createdAt} />
+                    <h2 className="text-muted mt-4">
+                      <i className="icon-ban" aria-hidden="true" />
+                      <span> Page is expired</span>
+                    </h2>
+                  </div>
+                )}
+
+                {(isShowSharedPage && shareLink != null) && (
+                  <>
+                    <ShareLinkAlert expiredAt={shareLink.expiredAt} createdAt={shareLink.createdAt} />
+                    <div className="d-flex flex-column flex-lg-row-reverse">
+
+                      <div className="grw-side-contents-container">
+                        <div className="grw-side-contents-sticky-container">
+
+                          {/* Page list */}
+                          <div className={`grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
+                            { shareLink.relatedPage.path != null && (
+                              <button
+                                type="button"
+                                className="btn btn-block btn-outline-secondary grw-btn-page-accessories
+                                rounded-pill d-flex justify-content-between align-items-center"
+                                onClick={() => openDescendantPageListModal(shareLink.relatedPage.path)}
+                                data-testid="pageListButton"
+                              >
+                                <div className="grw-page-accessories-control-icon">
+                                  <PageListIcon />
+                                </div>
+                                {t('page_list')}
+                                <CountBadge count={shareLink.relatedPage.descendantCount} offset={1} />
+                              </button>
+                            ) }
+                          </div>
+
+                          <div className="d-none d-lg-block">
+                            <TableOfContents />
+                          </div>
                         </div>
                       </div>
-                    </div>
 
-                    <div className="flex-grow-1 flex-basis-0 mw-0">
-                      <Page />
+                      <div className="flex-grow-1 flex-basis-0 mw-0">
+                        <Page />
+                      </div>
                     </div>
-                  </div>
-                </>
-              )}
+                  </>
+                )}
+              </div>
             </div>
           </div>
         </div>
-      </div>
-    </ShareLinkLayout>
+      </ShareLinkLayout>
+    </>
   );
 };
 
 function injectServerConfigurations(context: GetServerSidePropsContext, props: Props): void {
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
+  const { configManager, searchService, xssService } = crowi;
+
+  props.disableLinkSharing = configManager.getConfig('crowi', 'security:disableLinkSharing');
 
-  props.disableLinkSharing = crowi.configManager.getConfig('crowi', 'security:disableLinkSharing');
+  props.isSearchServiceConfigured = searchService.isConfigured;
+  props.isSearchServiceReachable = searchService.isReachable;
+  props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
 
-  props.isSearchServiceConfigured = crowi.searchService.isConfigured;
-  props.isSearchServiceReachable = crowi.searchService.isReachable;
-  props.isSearchScopeChildrenAsDefault = crowi.configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
+  props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
 
   props.rendererConfig = {
-    isEnabledLinebreaks: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
-    isEnabledLinebreaksInComments: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
-    adminPreferredIndentSize: crowi.configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
-    isIndentSizeForced: crowi.configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
+    isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
+    isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+    adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
+    isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
 
     plantumlUri: process.env.PLANTUML_URI ?? null,
     blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
 
     // XSS Options
-    isEnabledXssPrevention: crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
-    attrWhiteList: crowi.xssService.getAttrWhiteList(),
-    tagWhiteList: crowi.xssService.getTagWhiteList(),
-    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+    attrWhiteList: xssService.getAttrWhiteList(),
+    tagWhiteList: xssService.getTagWhiteList(),
+    highlightJsStyleBorder: configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
   };
 }
 

+ 1 - 1
packages/app/src/server/crowi/index.js

@@ -3,7 +3,7 @@ import http from 'http';
 import path from 'path';
 
 import { createTerminus } from '@godaddy/terminus';
-import lsxRoutes from '@growi/plugin-lsx/server/routes';
+import lsxRoutes from '@growi/remark-lsx/server/routes';
 import mongoose from 'mongoose';
 import next from 'next';
 

+ 5 - 0
packages/app/src/server/models/config.ts

@@ -3,6 +3,7 @@ import uniqueValidator from 'mongoose-unique-validator';
 
 import { GrowiThemes } from '~/interfaces/theme';
 
+import { RehypeSanitizeOption } from '../../interfaces/rehype';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 
@@ -154,6 +155,10 @@ export const defaultMarkdownConfigs: { [key: string]: any } = {
   'markdown:xss:option': 2,
   'markdown:xss:tagWhiteList': [],
   'markdown:xss:attrWhiteList': [],
+  'markdown:rehypeSanitize:isEnabledPrevention': true,
+  'markdown:rehypeSanitize:option': RehypeSanitizeOption.RECOMMENDED,
+  'markdown:rehypeSanitize:tagNames': [],
+  'markdown:rehypeSanitize:attributes': {},
   'markdown:isEnabledLinebreaks': false,
   'markdown:isEnabledLinebreaksInComments': true,
   'markdown:adminPreferredIndentSize': 4,

+ 1 - 1
packages/app/src/server/service/config-loader.ts

@@ -149,7 +149,7 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     ns:      'crowi',
     key:     'app:drawioUri',
     type:    ValueType.STRING,
-    default: 'https://embed.diagrams.net/',
+    default: null,
   },
   NCHAN_URI: {
     ns:      'crowi',

+ 0 - 38
packages/app/src/server/views/widget/headers/drawio.html

@@ -1,38 +0,0 @@
-<!-- draw.io -->
-{% if getConfig('crowi', 'app:drawioUri') %}
-<script type="text/javascript">
-  // refs: https://github.com/jgraph/drawio/blob/v13.4.3/etc/build/build.xml#L35-L38
-  let url = new URL("{{ getConfig('crowi', 'app:drawioUri') }}");
-  let origin = url.origin;
-  window.DRAWIO_BASE_URL = origin;
-  window.DRAWIO_LIGHTBOX_URL = origin;
-  window.STENCIL_PATH = [origin, 'stencils'].join('/');
-  window.SHAPES_PATH = [origin, 'shapes'].join('/');
-  window.mxBasePath = [origin, 'mxgraph'].join('/');
-</script>
-{% endif %}
-
-<script type="text/javascript">
-  // define callback function invoked by viewer.min.js of draw.io
-  // refs: https://github.com/jgraph/drawio/blob/v12.9.1/etc/build/build.xml#L219-L232
-  window.onDrawioViewerLoad = function() {
-    const DrawioViewer = window.GraphViewer;
-
-    if (DrawioViewer != null) {
-      // disable useResizeSensor and checkVisibleState
-      //   for preventing resize event by viewer.min.js
-      DrawioViewer.useResizeSensor = false;
-      DrawioViewer.prototype.checkVisibleState = false;
-
-      // Set responsive option.
-      // refs: https://github.com/jgraph/drawio/blob/v13.9.1/src/main/webapp/js/diagramly/GraphViewer.js#L89-L95
-      DrawioViewer.prototype.responsive = true;
-
-      // Set z-index ($zindex-dropdown + 200) for lightbox.
-      // 'lightbox' is like a modal dialog that appears when click on a drawio diagram.
-      // z-index refs: https://github.com/twbs/bootstrap/blob/v4.6.2/scss/_variables.scss#L681
-      DrawioViewer.prototype.lightboxZIndex = 1200;
-      DrawioViewer.prototype.toolbarZIndex = 1200;
-    }
-  };
-</script>

+ 0 - 156
packages/app/src/services/renderer/interceptor/drawio-interceptor.js

@@ -1,156 +0,0 @@
-/* eslint-disable import/prefer-default-export */
-import React from 'react';
-
-import { BasicInterceptor } from '@growi/core';
-import ReactDOM from 'react-dom';
-
-import Drawio from '~/components/Drawio';
-
-/**
- * The interceptor for draw.io
- *
- *  replace draw.io tag (render by markdown-it-drawio-viewer) to a React target element
- */
-export class DrawioInterceptor extends BasicInterceptor {
-
-  constructor() {
-    super();
-
-    this.previousPreviewContext = null;
-  }
-
-  /**
-   * @inheritdoc
-   */
-  isInterceptWhen(contextName) {
-    return (
-      contextName === 'preRenderHtml'
-      || contextName === 'preRenderPreviewHtml'
-      || contextName === 'postRenderHtml'
-      || contextName === 'postRenderPreviewHtml'
-    );
-  }
-
-  /**
-   * @inheritdoc
-   */
-  isProcessableParallel() {
-    return false;
-  }
-
-  /**
-   * @inheritdoc
-   */
-  async process(contextName, ...args) {
-    const context = Object.assign(args[0]); // clone
-
-    if (contextName === 'preRenderHtml' || contextName === 'preRenderPreviewHtml') {
-      return this.drawioPreRender(contextName, context);
-    }
-
-    if (contextName === 'postRenderHtml' || contextName === 'postRenderPreviewHtml') {
-      this.drawioPostRender(contextName, context);
-      return;
-    }
-  }
-
-  /**
-   * @inheritdoc
-   */
-  createRandomStr(length) {
-    const bag = 'abcdefghijklmnopqrstuvwxyz0123456789';
-    let generated = '';
-    for (let i = 0; i < length; i++) {
-      generated += bag[Math.floor(Math.random() * bag.length)];
-    }
-    return generated;
-  }
-
-  /**
-   * @inheritdoc
-   */
-  drawioPreRender(contextName, context) {
-    const div = document.createElement('div');
-    div.innerHTML = context.parsedHTML;
-
-    context.DrawioMap = {};
-    Array.from(div.querySelectorAll('.mxgraph')).forEach((element) => {
-      const domId = `mxgraph-${this.createRandomStr(8)}`;
-
-      context.DrawioMap[domId] = {
-        rangeLineNumberOfMarkdown: {
-          beginLineNumber: element.parentNode.dataset.beginLineNumberOfMarkdown,
-          endLineNumber: element.parentNode.dataset.endLineNumberOfMarkdown,
-        },
-        contentHtml: element.outerHTML,
-      };
-      element.outerHTML = `<div id="${domId}"></div>`;
-    });
-    context.parsedHTML = div.innerHTML;
-
-    // unmount
-    if (contextName === 'preRenderPreviewHtml') {
-      this.unmountPreviousReactDOMs(context);
-    }
-
-    // resolve
-    return context;
-  }
-
-  /**
-   * @inheritdoc
-   */
-  drawioPostRender(contextName, context) {
-    const isPreview = (contextName === 'postRenderPreviewHtml');
-    const renderDrawioInRealtime = context.renderDrawioInRealtime;
-
-    Object.keys(context.DrawioMap).forEach((domId) => {
-      const elem = document.getElementById(domId);
-      if (elem) {
-        if (isPreview && !renderDrawioInRealtime) {
-          this.renderDisabledDrawioReactDOM(context.DrawioMap[domId], elem, isPreview);
-        }
-        else {
-          this.renderReactDOM(context.DrawioMap[domId], elem, isPreview);
-        }
-      }
-    });
-  }
-
-  /**
-   * @inheritdoc
-   */
-  renderReactDOM(drawioMapEntry, elem, isPreview) {
-    ReactDOM.render(
-      // eslint-disable-next-line react/jsx-filename-extension
-      <Drawio
-        drawioContent={drawioMapEntry.contentHtml}
-        isPreview={isPreview}
-        rangeLineNumberOfMarkdown={drawioMapEntry.rangeLineNumberOfMarkdown}
-      />,
-      elem,
-    );
-  }
-
-  renderDisabledDrawioReactDOM(drawioMapEntry, elem, isPreview) {
-    ReactDOM.render(
-      // eslint-disable-next-line react/jsx-filename-extension
-      <div className="alert alert-light text-dark">Rendering of draw.io is disabled.</div>,
-      elem,
-    );
-  }
-
-  /**
-   * @inheritdoc
-   */
-  unmountPreviousReactDOMs(newContext) {
-    if (this.previousPreviewContext != null) {
-      Array.from(document.querySelectorAll('.mxgraph')).forEach((element) => {
-        ReactDOM.unmountComponentAtNode(element);
-      });
-    }
-
-    this.previousPreviewContext = newContext;
-  }
-
-}

+ 19 - 6
packages/app/src/services/renderer/renderer.tsx

@@ -1,8 +1,9 @@
 // allow only types to import from react
 import { ComponentType } from 'react';
 
-import { Lsx } from '@growi/plugin-lsx/components';
-import * as lsxGrowiPlugin from '@growi/plugin-lsx/services/renderer';
+import { Lsx, LsxImmutable } from '@growi/remark-lsx/components';
+import * as lsxGrowiPlugin from '@growi/remark-lsx/services/renderer';
+import * as drawioPlugin from '@growi/remark-drawio-plugin';
 import growiPlugin from '@growi/remark-growi-plugin';
 import { Schema as SanitizeOption } from 'hast-util-sanitize';
 import { SpecialComponents } from 'react-markdown/lib/ast-to-react';
@@ -22,8 +23,10 @@ import { PluggableList, Pluggable, PluginTuple } from 'unified';
 
 
 import { CodeBlock } from '~/components/ReactMarkdownComponents/CodeBlock';
+import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents/DrawioViewerWithEditButton';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
+import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
 import { RendererConfig } from '~/interfaces/services/renderer';
 import loggerFactory from '~/utils/logger';
 
@@ -320,6 +323,7 @@ export const generateViewOptions = (
   remarkPlugins.push(
     math,
     [plantuml.remarkPlugin, { baseUrl: config.plantumlUri }],
+    drawioPlugin.remarkPlugin,
     xsvToTable.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
   );
@@ -333,6 +337,7 @@ export const generateViewOptions = (
     [lsxGrowiPlugin.rehypePlugin, { pagePath }],
     [sanitize, deepmerge(
       commonSanitizeOption,
+      drawioPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
     )],
     katex,
@@ -347,7 +352,9 @@ export const generateViewOptions = (
     components.h1 = Header;
     components.h2 = Header;
     components.h3 = Header;
-    components.lsx = props => <Lsx {...props} forceToFetchData />;
+    components.lsx = Lsx;
+    components.drawio = DrawioViewerWithEditButton;
+    components.table = TableWithEditButton;
   }
 
   // // Add configurers for viewer
@@ -397,6 +404,7 @@ export const generateSimpleViewOptions = (config: RendererConfig, pagePath: stri
   remarkPlugins.push(
     math,
     [plantuml.remarkPlugin, { baseUrl: config.plantumlUri }],
+    drawioPlugin.remarkPlugin,
     xsvToTable.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
   );
@@ -410,6 +418,7 @@ export const generateSimpleViewOptions = (config: RendererConfig, pagePath: stri
     [keywordHighlighter.rehypePlugin, { keywords: highlightKeywords }],
     [sanitize, deepmerge(
       commonSanitizeOption,
+      drawioPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
     )],
     katex,
@@ -417,7 +426,8 @@ export const generateSimpleViewOptions = (config: RendererConfig, pagePath: stri
 
   // add components
   if (components != null) {
-    components.lsx = props => <Lsx {...props} />;
+    components.lsx = LsxImmutable;
+    components.drawio = drawioPlugin.DrawioViewer;
   }
 
   verifySanitizePlugin(options, false);
@@ -433,6 +443,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
   remarkPlugins.push(
     math,
     [plantuml.remarkPlugin, { baseUrl: config.plantumlUri }],
+    drawioPlugin.remarkPlugin,
     xsvToTable.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
   );
@@ -447,6 +458,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     [sanitize, deepmerge(
       commonSanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
+      drawioPlugin.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
     )],
     katex,
@@ -454,10 +466,11 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
 
   // add components
   if (components != null) {
-    components.lsx = props => <Lsx {...props} />;
+    components.lsx = LsxImmutable;
+    components.drawio = drawioPlugin.DrawioViewer;
   }
 
-  verifySanitizePlugin(options, false);
+  // verifySanitizePlugin(options, false);
   return options;
 };
 

+ 2 - 6
packages/app/src/stores/context.tsx

@@ -89,8 +89,8 @@ export const useRegistrationWhiteList = (initialData?: Nullable<string[]>): SWRR
   return useContextSWR<Nullable<string[]>, Error>('registrationWhiteList', initialData);
 };
 
-export const useDrawioUri = (initialData?: string): SWRResponse<string, Error> => {
-  return useContextSWR('drawioUri', initialData, { fallbackData: 'https://embed.diagrams.net/' });
+export const useDrawioUri = (initialData?: Nullable<string>): SWRResponse<string, Error> => {
+  return useContextSWR('drawioUri', initialData ?? undefined, { fallbackData: 'https://embed.diagrams.net/' });
 };
 
 export const useHackmdUri = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
@@ -182,10 +182,6 @@ export const useIsBlinkedHeaderAtBoot = (initialData?: boolean): SWRResponse<boo
   return useContextSWR('isBlinkedAtBoot', initialData, { fallbackData: false });
 };
 
-export const useEditingMarkdown = (initialData?: string): SWRResponse<string, Error> => {
-  return useContextSWR('currentMarkdown', initialData);
-};
-
 export const useIsUploadableImage = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useContextSWR('isUploadableImage', initialData);
 };

+ 6 - 1
packages/app/src/stores/editor.tsx

@@ -1,5 +1,5 @@
 import { Nullable, withUtils, SWRResponseWithUtils } from '@growi/core';
-import useSWR, { MutatorOptions, SWRResponse, useSWRConfig } from 'swr';
+import useSWR, { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiGet } from '~/client/util/apiv1-client';
@@ -15,6 +15,11 @@ import { useSWRxTagsInfo } from './page';
 import { useStaticSWR } from './use-static-swr';
 
 
+export const useEditingMarkdown = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR('editingMarkdown', initialData);
+};
+
+
 type EditorSettingsOperation = {
   update: (updateData: Partial<IEditorSettings>) => Promise<void>,
   turnOffAskingBeforeDownloadLargeFiles: () => void,

+ 2 - 1
packages/app/src/stores/middlewares/sync-to-storage.ts

@@ -35,10 +35,11 @@ export const createSyncToStorageMiddlware = (
 
       config.fallbackData = initData;
       const swrNext = useSWRNext(key, fetcher, config);
+      const swrMutate = swrNext.mutate;
 
       return Object.assign(swrNext, {
         mutate: (data, shouldRevalidate) => {
-          return swrNext.mutate(data, shouldRevalidate)
+          return swrMutate(data, shouldRevalidate)
             .then((value) => {
               storage.setItem(keyInStorage, storageSerializer.serialize(value));
               return value;

+ 62 - 19
packages/app/src/stores/modal.tsx

@@ -1,3 +1,5 @@
+import { useCallback } from 'react';
+
 import { SWRResponse } from 'swr';
 
 import MarkdownTable from '~/client/models/MarkdownTable';
@@ -444,13 +446,19 @@ export const useShortcutsModal = (): SWRResponse<ShortcutsModalStatus, Error> &
 * DrawioModal
 */
 
+type DrawioModalSaveHandler = (drawioMxFile: string) => void;
+
 type DrawioModalStatus = {
   isOpened: boolean,
   drawioMxFile: string,
+  onSave?: DrawioModalSaveHandler,
 }
 
 type DrawioModalStatusUtils = {
-  open(drawioMxFile: string): void,
+  open(
+    drawioMxFile: string,
+    onSave?: DrawioModalSaveHandler,
+  ): void,
   close(): void,
 }
 
@@ -461,13 +469,15 @@ export const useDrawioModal = (status?: DrawioModalStatus): SWRResponse<DrawioMo
   };
   const swrResponse = useStaticSWR<DrawioModalStatus, Error>('drawioModalStatus', status, { fallbackData: initialData });
 
-  const open = (drawioMxFile: string): void => {
-    swrResponse.mutate({ isOpened: true, drawioMxFile });
-  };
+  const { mutate } = swrResponse;
 
-  const close = (): void => {
-    swrResponse.mutate({ isOpened: false, drawioMxFile: '' });
-  };
+  const open = useCallback((drawioMxFile: string, onSave?: DrawioModalSaveHandler): void => {
+    mutate({ isOpened: true, drawioMxFile, onSave });
+  }, [mutate]);
+
+  const close = useCallback((): void => {
+    mutate({ isOpened: false, drawioMxFile: '', onSave: undefined });
+  }, [mutate]);
 
   return {
     ...swrResponse,
@@ -479,30 +489,63 @@ export const useDrawioModal = (status?: DrawioModalStatus): SWRResponse<DrawioMo
 /*
 * HandsonTableModal
 */
+type HandsonTableModalSaveHandler = (table: MarkdownTable) => void;
+
 type HandsontableModalStatus = {
   isOpened: boolean,
-  table?: MarkdownTable,
-  editor: any,
-  autoFormatMarkdownTable: boolean,
+  table: MarkdownTable,
+  // TODO: Define editor type
+  editor?: any,
+  autoFormatMarkdownTable?: boolean,
+  // onSave is passed only when editing table directly from the page.
+  onSave?: HandsonTableModalSaveHandler
 }
 
 type HandsontableModalStatusUtils = {
-  open(table: MarkdownTable, editor: any, autoFormatMarkdownTable: boolean): Promise<HandsontableModalStatus | undefined>
-  close(): Promise<HandsontableModalStatus | undefined>
+  open(
+    table: MarkdownTable,
+    editor?: any,
+    autoFormatMarkdownTable?: boolean,
+    onSave?: HandsonTableModalSaveHandler
+  ): void
+  close(): void
 }
 
+const defaultMarkdownTable = () => {
+  return new MarkdownTable(
+    [
+      ['col1', 'col2', 'col3'],
+      ['', '', ''],
+      ['', '', ''],
+    ],
+    {
+      align: ['', '', ''],
+    },
+  );
+};
+
 export const useHandsontableModal = (status?: HandsontableModalStatus): SWRResponse<HandsontableModalStatus, Error> & HandsontableModalStatusUtils => {
   const initialData: HandsontableModalStatus = {
-    isOpened: false, table: undefined, editor: undefined, autoFormatMarkdownTable: false,
+    isOpened: false,
+    table: defaultMarkdownTable(),
+    editor: undefined,
+    autoFormatMarkdownTable: false,
   };
+
   const swrResponse = useStaticSWR<HandsontableModalStatus, Error>('handsontableModalStatus', status, { fallbackData: initialData });
 
-  const open = (table: MarkdownTable, editor: any, autoFormatMarkdownTable: boolean) => swrResponse.mutate({
-    isOpened: true, table, editor, autoFormatMarkdownTable,
-  });
-  const close = () => swrResponse.mutate({
-    isOpened: false, table: undefined, editor: undefined, autoFormatMarkdownTable: false,
-  });
+  const { mutate } = swrResponse;
+
+  const open = useCallback((table: MarkdownTable, editor?: any, autoFormatMarkdownTable?: boolean, onSave?: HandsonTableModalSaveHandler): void => {
+    mutate({
+      isOpened: true, table, editor, autoFormatMarkdownTable, onSave,
+    });
+  }, [mutate]);
+  const close = useCallback((): void => {
+    mutate({
+      isOpened: false, table: defaultMarkdownTable(), editor: undefined, autoFormatMarkdownTable: false, onSave: undefined,
+    });
+  }, [mutate]);
 
   return {
     ...swrResponse,

+ 0 - 41
packages/app/src/styles/_page.scss

@@ -1,47 +1,6 @@
 // // import diff2html styles
 // @import '~/diff2html/bundles/css/diff2html.min.css';
 
-/**
- * for table with handsontable modal button
- */
-.editable-with-handsontable {
-  position: relative;
-
-  .handsontable-modal-trigger {
-    position: absolute;
-    top: 11px;
-    right: 10px;
-    padding: 0;
-    font-size: 16px;
-    line-height: 1;
-    vertical-align: bottom;
-    background-color: transparent;
-    border: none;
-    opacity: 0;
-  }
-
-  .page-mobile & .handsontable-modal-trigger {
-    opacity: 0.3;
-  }
-
-  &:hover .handsontable-modal-trigger {
-    opacity: 1;
-  }
-}
-
-/**
- * for drawio with drawio iframe button
- */
-.editable-with-drawio {
-  .drawio-iframe-trigger {
-    top: 11px;
-    right: 10px;
-    z-index: 14;
-    font-size: 12px;
-    line-height: 1;
-  }
-}
-
 .card.grw-page-status-alert {
   $margin-bottom: $grw-navbar-bottom-height + 10px;
 

+ 7 - 0
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -516,6 +516,13 @@ ul.pagination {
   }
 }
 
+/*
+ * drawio
+ */
+.drawio-viewer {
+  border-color: $border-color-global;
+}
+
 /*
  * modal
  */

+ 7 - 0
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -401,6 +401,13 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
   }
 }
 
+/*
+ * drawio
+ */
+.drawio-viewer {
+  border-color: $border-color-global;
+}
+
 /*
  * admin settings
  */

+ 33 - 25
packages/app/test/cypress/integration/20-basic-features/access-to-page.spec.ts

@@ -11,27 +11,33 @@ context('Access to page', () => {
   });
 
   it('/Sandbox is successfully loaded', () => {
-    cy.visit('/Sandbox', {  });
-    cy.screenshot(`${ssPrefix}-sandbox`);
-  });
-
-  it('/Sandbox with anchor hash is successfully loaded', () => {
-    cy.visit('/Sandbox#Headers');
+    cy.visit('/Sandbox');
     cy.waitUntilSkeletonDisappear();
 
     // for check download toc data
     cy.get('.toc-link').should('be.visible');
 
-    // hide fab // disable fab for sticky-events warning
-    // cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
+    cy.screenshot(`${ssPrefix}-sandbox`);
+  });
+
+  // TODO: https://redmine.weseek.co.jp/issues/109939
+  // it('/Sandbox with anchor hash is successfully loaded', () => {
+  //   cy.visit('/Sandbox#Headers');
+  //   cy.waitUntilSkeletonDisappear();
 
-    // remove animation for screenshot
-    // remove 'blink' class because ::after element cannot be operated
-    // https://stackoverflow.com/questions/5041494/selecting-and-manipulating-css-pseudo-elements-such-as-before-and-after-usin/21709814#21709814
-    cy.get('#mdcont-headers').invoke('removeClass', 'blink');
+  //   // for check download toc data
+  //   cy.get('.toc-link').should('be.visible');
 
-    cy.screenshot(`${ssPrefix}-sandbox-headers`);
-  });
+  //   // hide fab // disable fab for sticky-events warning
+  //   // cy.getByTestid('grw-fab-container').invoke('attr', 'style', 'display: none');
+
+  //   // remove animation for screenshot
+  //   // remove 'blink' class because ::after element cannot be operated
+  //   // https://stackoverflow.com/questions/5041494/selecting-and-manipulating-css-pseudo-elements-such-as-before-and-after-usin/21709814#21709814
+  //   cy.get('#mdcont-headers').invoke('removeClass', 'blink');
+
+  //   cy.screenshot(`${ssPrefix}-sandbox-headers`);
+  // });
 
   it('/Sandbox/Math is successfully loaded', () => {
     cy.visit('/Sandbox/Math');
@@ -47,18 +53,20 @@ context('Access to page', () => {
     cy.visit('/Sandbox');
     cy.waitUntilSkeletonDisappear();
 
-    cy.get('#grw-subnav-container', { timeout: 30000 }).should('be.visible').within(() => {
+    cy.get('#grw-subnav-container').should('be.visible').within(() => {
 
       // eslint-disable-next-line cypress/no-unnecessary-waiting
       cy.wait(2000);
-      cy.getByTestid('editor-button', { timeout: 30000 }).should('be.visible').click();
+      cy.getByTestid('editor-button').should('be.visible').click();
     })
-    cy.getByTestid('navbar-editor', { timeout: 30000 }).should('be.visible');
+    cy.getByTestid('navbar-editor').should('be.visible');
+    cy.get('.grw-editor-navbar-bottom').should('be.visible');
+
     cy.screenshot(`${ssPrefix}-Sandbox-edit-page`);
   })
 
   it('/user/admin is successfully loaded', () => {
-    cy.visit('/user/admin', {  });
+    cy.visit('/user/admin');
 
     cy.waitUntilSkeletonDisappear();
     // for check download toc data
@@ -86,7 +94,7 @@ context('Access to /me page', () => {
   });
 
   it('/me is successfully loaded', () => {
-    cy.visit('/me', {  });
+    cy.visit('/me');
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(500); // wait loading image
     cy.screenshot(`${ssPrefix}-me`);
@@ -99,8 +107,6 @@ context('Access to /me page', () => {
 
 });
 
-
-
 context('Access to special pages', () => {
   const ssPrefix = 'access-to-special-pages-';
 
@@ -114,8 +120,10 @@ context('Access to special pages', () => {
   });
 
   it('/trash is successfully loaded', () => {
-    cy.visit('/trash', {  });
-    cy.getByTestid('trash-page-list').should('be.visible');
+    cy.visit('/trash');
+
+    cy.getByTestid('trash-page-list').contains('There are no pages under this page.');
+
     cy.screenshot(`${ssPrefix}-trash`);
   });
 
@@ -226,12 +234,12 @@ context('Access to /me/all-in-app-notifications', () => {
     cy.getByTestid('grw-in-app-notification-page').should('be.visible');
     cy.getByTestid('grw-in-app-notification-page-spinner').should('not.exist');
 
-    cy.screenshot(`${ssPrefix}-see-all`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-see-all`);
 
     cy.get('.grw-custom-nav-tab > div > ul > li:nth-child(2) > a').click();
     cy.getByTestid('grw-in-app-notification-page-spinner').should('not.exist');
 
-    cy.screenshot(`${ssPrefix}-see-unread`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-see-unread`);
    });
 
 })

+ 11 - 1
packages/app/test/cypress/integration/20-basic-features/access-to-pagelist.spec.ts

@@ -11,8 +11,18 @@ context('Access to pagelist', () => {
 
   it('Page list modal is successfully opened ', () => {
     cy.visit('/');
+    cy.waitUntilSkeletonDisappear();
+
     cy.getByTestid('pageListButton').click({force: true});
-    cy.getByTestid('page-accessories-modal').should('be.visible').screenshot(`${ssPrefix}1-open-pagelist-modal`);
+    cy.getByTestid('page-accessories-modal').parent().should('have.class','show');
+    cy.getByTestid('page-list-item-L').should('be.visible');
+
+    // Wait until the string "You cannot see this page" is no longer displayed
+    cy.getByTestid('page-list-item-L').eq(0).within(() => {
+      cy.get('.icon-exclamation').should('not.exist');
+    });
+
+    cy.screenshot(`${ssPrefix}1-open-pagelist-modal`);
   });
 
   it('Successfully duplicate a page from page list', () => {

+ 121 - 59
packages/app/test/cypress/integration/20-basic-features/click-page-icons.spec.ts

@@ -12,83 +12,145 @@ context('Click page icons button', () => {
 
   it('Successfully subscribe/unsubscribe a page', () => {
     cy.visit('/Sandbox');
-    cy.get('#grw-subnav-container').within(() => {
-      // Subscribe
-      cy.get('#subscribe-button').click({force: true});
-      cy.get('#subscribe-button').should('have.class', 'active');
-      cy.screenshot(`${ssPrefix}1-subscribe-page`);
-
-      // Unsubscribe
-      cy.get('#subscribe-button.active').click({force: true});
-      cy.get('#subscribe-button').should('not.have.class', 'active');
-      cy.screenshot(`${ssPrefix}2-unsubscribe-page`);
-    });
+    cy.waitUntilSkeletonDisappear();
+
+    // Subscribe
+    cy.get('#subscribe-button').click({force: true});
+    cy.get('#subscribe-button').should('have.class', 'active');
+
+    // position of the element is not fixed to be displayed, so the element is removed
+    cy.get('body').then($body => {
+      if ($body.find('[data-testid="subscribe-button-tooltip"]').length > 0) {
+        cy.getByTestid('subscribe-button-tooltip').invoke('remove');
+      }
+    })
+    cy.getByTestid('subscribe-button-tooltip').should('not.exist');
+
+    cy.get('#grw-subnav-container').within(() => { cy.screenshot(`${ssPrefix}1-subscribe-page`) })
+
+    // Unsubscribe
+    cy.get('#subscribe-button').click({force: true});
+    cy.get('#subscribe-button').should('not.have.class', 'active');
+
+    // position of the element is not fixed to be displayed, so the element is removed
+    cy.get('body').then($body => {
+      if ($body.find('[data-testid="subscribe-button-tooltip"]').length > 0) {
+        cy.getByTestid('subscribe-button-tooltip').invoke('remove');
+      }
+    })
+    cy.getByTestid('subscribe-button-tooltip').should('not.exist');
+
+    cy.get('#grw-subnav-container').within(() => { cy.screenshot(`${ssPrefix}2-unsubscribe-page`) })
   });
 
   it('Successfully Like / Dislike a page', () => {
     cy.visit('/Sandbox');
-    cy.get('#grw-subnav-container').within(() => {
-      cy.get('#like-button').click({force: true});
-      cy.get('#like-button').should('have.class', 'active');
-      cy.screenshot(`${ssPrefix}3-like-page`);
-      cy.get('#po-total-likes').click({force: true});
-    });
-    cy.get('.user-list-popover').should('be.visible');
+    cy.waitUntilSkeletonDisappear();
 
-    cy.get('#grw-subnav-container').within(() => {
-      cy.screenshot(`${ssPrefix}4-likes-counter`);
-      cy.get('#like-button.active').click({force: true});
-      cy.get('#like-button').should('not.have.class', 'active');
-      cy.screenshot(`${ssPrefix}5-dislike-page`);
-      cy.get('#po-total-likes').click({force: true});
-    });
+    // like
+    cy.get('#like-button').click({force: true});
+    cy.get('#like-button').should('have.class', 'active');
 
-    cy.get('.user-list-popover').should('be.visible');
+    // position of the element is not fixed to be displayed, so the element is removed
+    cy.get('body').then($body => {
+      if ($body.find('[data-testid="like-button-tooltip"]').length > 0) {
+        cy.getByTestid('like-button-tooltip').invoke('remove');
+      }
+    })
+    cy.getByTestid('like-button-tooltip').should('not.exist');
 
-    cy.get('#grw-subnav-container').within(() => {
-      cy.screenshot(`${ssPrefix}6-likes-counter`);
-    });
+    cy.get('#grw-subnav-container').within(() => { cy.screenshot(`${ssPrefix}3-like-page`) });
+
+    // total liker (user-list-popover is commented out because it is sometimes displayed and sometimes not.)
+    // cy.get('#po-total-likes').click({force: true});
+    // cy.get('.user-list-popover').should('be.visible')
+    // cy.get('#grw-subnav-container').within(() => { cy.screenshot(`${ssPrefix}4-likes-counter`) });
+
+    // unlike
+    cy.get('#like-button').click({force: true});
+    cy.get('#like-button').should('not.have.class', 'active');
+
+    // position of the element is not fixed to be displayed, so the element is removed
+    cy.get('body').then($body => {
+      if ($body.find('[data-testid="like-button-tooltip"]').length > 0) {
+        cy.getByTestid('like-button-tooltip').invoke('remove');
+      }
+    })
+    cy.getByTestid('like-button-tooltip').should('not.exist');
+
+    cy.get('#grw-subnav-container').within(() => { cy.screenshot(`${ssPrefix}5-dislike-page`) });
+
+    // total liker (user-list-popover is commented out because it is sometimes displayed and sometimes not.)
+    // cy.get('#po-total-likes').click({force: true});
+    // cy.get('.user-list-popover').should('be.visible');
+    // cy.get('#grw-subnav-container').within(() => { cy.screenshot(`${ssPrefix}6-likes-counter`) });
   });
 
   it('Successfully Bookmark / Unbookmark a page', () => {
     cy.visit('/Sandbox');
-    cy.get('#grw-subnav-container').within(() => {
-      cy.get('#bookmark-button').click({force: true});
-      cy.get('#bookmark-button').should('have.class', 'active');
-      cy.screenshot(`${ssPrefix}7-bookmark-page`);
-      cy.get('#po-total-bookmarks').click({force: true});
-    });
-    cy.get('.user-list-popover').should('be.visible');
+    cy.waitUntilSkeletonDisappear();
 
-    cy.get('#grw-subnav-container').within(() => {
-      cy.screenshot(`${ssPrefix}8-bookmarks-counter`);
-      cy.get('#bookmark-button.active').click({force: true});
-      cy.get('#bookmark-button').should('not.have.class', 'active');
-      cy.screenshot(`${ssPrefix}9-unbookmark-page`);
-      cy.get('#po-total-bookmarks').click({force: true});
-    });
+    // bookmark
+    cy.get('#bookmark-button').click({force: true});
+    cy.get('#bookmark-button').should('have.class', 'active');
+
+    // position of the element is not fixed to be displayed, so the element is removed
+    cy.get('body').then($body => {
+      if ($body.find('[data-testid="bookmark-button-tooltip"]').length > 0) {
+        cy.getByTestid('bookmark-button-tooltip').invoke('remove');
+      }
+    })
+    cy.getByTestid('bookmark-button-tooltip').should('not.exist');
 
+    cy.get('#grw-subnav-container').within(() => { cy.screenshot(`${ssPrefix}7-bookmark-page`) });
+
+    // total bookmarker
+    cy.get('#po-total-bookmarks').click({force: true});
     cy.get('.user-list-popover').should('be.visible');
+    cy.get('#grw-subnav-container').within(() => { cy.screenshot(`${ssPrefix}8-bookmarks-counter`) });
 
-    cy.get('#grw-subnav-container').within(() => {
-      cy.screenshot(`${ssPrefix}10-bookmarks-counter`);
-    });
-  });
+    // unbookmark
+    cy.get('#bookmark-button').click({force: true});
+    cy.get('#bookmark-button').should('not.have.class', 'active');
 
-  it('Successfully display list of "seen by user"', () => {
-    cy.visit('/Sandbox');
-    cy.waitUntilSkeletonDisappear();
-    // eslint-disable-next-line cypress/no-unnecessary-waiting
-    cy.wait(2000); // wait for get method
-    cy.get('#grw-subnav-container').within(() => {
-      cy.get('div.grw-seen-user-info').find('button#btn-seen-user').click({force: true});
-    });
+    // position of the element is not fixed to be displayed, so the element is removed
+    cy.get('body').then($body => {
+      if ($body.find('[data-testid="bookmark-button-tooltip"]').length > 0) {
+        cy.getByTestid('bookmark-button-tooltip').invoke('remove');
+      }
+    })
+    cy.getByTestid('bookmark-button-tooltip').should('not.exist');
 
-    cy.get('.user-list-popover').should('be.visible')
+    cy.get('#grw-subnav-container').within(() => { cy.screenshot(`${ssPrefix}9-unbookmark-page`) });
 
-    cy.get('#grw-subnav-container').within(() => {
-      cy.screenshot(`${ssPrefix}11-seen-user-list`);
-    });
+    // total bookmarker
+    cy.get('#po-total-bookmarks').click({force: true});
+    cy.get('.user-list-popover').should('be.visible');
+    cy.get('#grw-subnav-container').within(() => { cy.screenshot(`${ssPrefix}10-bookmarks-counter`) });
   });
 
+  // user-list-popover is commented out because it is sometimes displayed and sometimes not
+  // it('Successfully display list of "seen by user"', () => {
+  //   cy.visit('/Sandbox');
+  //   cy.waitUntilSkeletonDisappear();
+
+  //   cy.get('#grw-subnav-container').within(() => {
+  //     cy.get('div.grw-seen-user-info').find('button#btn-seen-user').click({force: true});
+  //   });
+
+  //   // position of the element is not fixed to be displayed, so the element is removed
+  //   cy.get('body').then($body => {
+  //     if ($body.find('[data-testid="seen-user-info-tooltip"]').length > 0) {
+  //       cy.getByTestid('seen-user-info-tooltip').invoke('remove');
+  //     }
+  //   })
+  //   cy.getByTestid('seen-user-info-tooltip').should('not.exist');
+
+  //   cy.get('.user-list-popover').should('be.visible')
+
+  //   cy.get('#grw-subnav-container').within(() => {
+  //     cy.screenshot(`${ssPrefix}11-seen-user-list`);
+  //   });
+  // });
+
 });

+ 93 - 50
packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts

@@ -11,15 +11,15 @@ context('Switch Sidebar content', () => {
   it('PageTree is successfully shown', () => {
     cy.collapseSidebar(false);
     cy.visit('/page');
+    cy.waitUntilSkeletonDisappear();
+
     cy.getByTestid('grw-sidebar-nav-primary-page-tree').click();
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(1500);
-    cy.screenshot(`${ssPrefix}-pagetree-after-load`, { capture: 'viewport' });
+    cy.screenshot(`${ssPrefix}-pagetree-after-load`);
   });
-
 });
 
-
 context('Modal for page operation', () => {
 
   const ssPrefix = 'modal-for-page-operation-';
@@ -31,8 +31,11 @@ context('Modal for page operation', () => {
     });
     cy.collapseSidebar(true);
   });
+
   it("PageCreateModal is shown and closed successfully", () => {
     cy.visit('/');
+    cy.waitUntilSkeletonDisappear();
+
     cy.getByTestid('newPageBtn').click();
 
     cy.getByTestid('page-create-modal').should('be.visible').within(() => {
@@ -40,11 +43,14 @@ context('Modal for page operation', () => {
       cy.get('button.close').click();
 
     });
-    cy.screenshot(`${ssPrefix}page-create-modal-closed`, {capture: 'viewport'});
+    cy.screenshot(`${ssPrefix}page-create-modal-closed`);
   });
+
   it("Successfully Create Today's page", () => {
     const pageName = "Today's page";
     cy.visit('/');
+    cy.waitUntilSkeletonDisappear();
+
     cy.getByTestid('newPageBtn').click();
 
     cy.getByTestid('page-create-modal').should('be.visible').within(() => {
@@ -57,12 +63,22 @@ context('Modal for page operation', () => {
     cy.get('.layout-root').should('not.have.class', 'editing');
 
     cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
+
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(300);
+
+    // Do not use "cy.waitUntilSkeletonDisappear()"
+    cy.get('.grw-skeleton').should('not.exist');
+
     cy.screenshot(`${ssPrefix}create-today-page`);
   });
+
   it('Successfully create page under specific path', () => {
     const pageName = 'child';
 
-    cy.visit('/SandBox');
+    cy.visit('/Sandbox');
+    cy.waitUntilSkeletonDisappear();
+
     cy.getByTestid('newPageBtn').click();
 
     cy.getByTestid('page-create-modal').should('be.visible').within(() => {
@@ -75,12 +91,18 @@ context('Modal for page operation', () => {
     cy.get('.layout-root').should('not.have.class', 'editing');
 
     cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
+
+    // eslint-disable-next-line cypress/no-unnecessary-waiting
+    cy.wait(300);
+
+    // Do not use "cy.waitUntilSkeletonDisappear()"
+    cy.get('.grw-skeleton').should('not.exist');
+
     cy.screenshot(`${ssPrefix}create-page-under-specific-page`);
   });
 
   it('Trying to create template page under the root page fail', () => {
     cy.visit('/');
-
     cy.waitUntilSkeletonDisappear();
 
     cy.getByTestid('newPageBtn').click();
@@ -94,7 +116,7 @@ context('Modal for page operation', () => {
       cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
     });
     cy.get('.toast-error').should('be.visible').invoke('attr', 'style', 'opacity: 1');
-    cy.screenshot(`${ssPrefix}create-template-for-children-error`, {capture: 'viewport'});
+    cy.screenshot(`${ssPrefix}create-template-for-children-error`);
     cy.get('.toast-error').should('be.visible').click();
     cy.get('.toast-error').should('not.exist');
 
@@ -104,11 +126,12 @@ context('Modal for page operation', () => {
       cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
     });
     cy.get('.toast-error').should('be.visible').invoke('attr', 'style', 'opacity: 1');
-    cy.screenshot(`${ssPrefix}create-template-for-descendants-error`, {capture: 'viewport'});
+    cy.screenshot(`${ssPrefix}create-template-for-descendants-error`);
   });
 
   it('PageDeleteModal is shown successfully', () => {
     cy.visit('/Sandbox/Bootstrap4');
+    cy.waitUntilSkeletonDisappear();
 
      cy.get('#grw-subnav-container').within(() => {
        cy.getByTestid('open-page-item-control-btn').click({force: true});
@@ -119,7 +142,8 @@ context('Modal for page operation', () => {
   });
 
   it('PageDuplicateModal is shown successfully', () => {
-    cy.visit('/Sandbox/Bootstrap4', {  });
+    cy.visit('/Sandbox/Bootstrap4');
+    cy.waitUntilSkeletonDisappear();
 
     cy.get('#grw-subnav-container').within(() => {
       cy.getByTestid('open-page-item-control-btn').click({force: true});
@@ -130,45 +154,49 @@ context('Modal for page operation', () => {
   });
 
   it('PageMoveRenameModal is shown successfully', () => {
-    cy.visit('/Sandbox/Bootstrap4', {  });
+    cy.visit('/Sandbox/Bootstrap4');
+    cy.waitUntilSkeletonDisappear();
 
     cy.get('#grw-subnav-container').within(() => {
       cy.getByTestid('open-page-item-control-btn').click({force: true});
       cy.getByTestid('open-page-move-rename-modal-btn').click({force: true});
     });
 
+    cy.getByTestid('grw-page-rename-button').should('be.disabled');
+
     cy.getByTestid('page-rename-modal').should('be.visible').screenshot(`${ssPrefix}-rename-bootstrap4`);
   });
 
 });
 
 
-context('Open presentation modal', () => {
+// TODO: Uncomment after https://redmine.weseek.co.jp/issues/103121 is resolved
+// context('Open presentation modal', () => {
 
-  const ssPrefix = 'access-to-presentation-modal-';
+//   const ssPrefix = 'access-to-presentation-modal-';
 
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-    cy.collapseSidebar(true);
-  });
+//   beforeEach(() => {
+//     // login
+//     cy.fixture("user-admin.json").then(user => {
+//       cy.login(user.username, user.password);
+//     });
+//     cy.collapseSidebar(true);
+//   });
 
-  it('PresentationModal for "/" is shown successfully', () => {
-    cy.visit('/');
+//   it('PresentationModal for "/" is shown successfully', () => {
+//     cy.visit('/');
 
-    cy.get('#grw-subnav-container').within(() => {
-      cy.getByTestid('open-page-item-control-btn').click({force: true});
-      cy.getByTestid('open-presentation-modal-btn').click({force: true});
-    });
+//     cy.get('#grw-subnav-container').within(() => {
+//       cy.getByTestid('open-page-item-control-btn').click({force: true});
+//       cy.getByTestid('open-presentation-modal-btn').click({force: true});
+//     });
 
-    // eslint-disable-next-line cypress/no-unnecessary-waiting
-    cy.wait(1500);
-    cy.screenshot(`${ssPrefix}-open-top`);
-  });
+//     // eslint-disable-next-line cypress/no-unnecessary-waiting
+//     cy.wait(1500);
+//     cy.screenshot(`${ssPrefix}-open-top`);
+//   });
 
-});
+// });
 
 context('Page Accessories Modal', () => {
 
@@ -184,6 +212,8 @@ context('Page Accessories Modal', () => {
 
   it('Page History is shown successfully', () => {
      cy.visit('/Sandbox/Bootstrap4');
+     cy.waitUntilSkeletonDisappear();
+
      cy.get('#grw-subnav-container').within(() => {
       cy.getByTestid('open-page-item-control-btn').within(() => {
         cy.get('button.btn-page-item-control').click({force: true});
@@ -194,22 +224,29 @@ context('Page Accessories Modal', () => {
      cy.getByTestid('page-history').should('be.visible')
      cy.screenshot(`${ssPrefix}-open-page-history-bootstrap4`);
   });
+
   it('Page Attachment Data is shown successfully', () => {
-     cy.visit('/Sandbox/Bootstrap4', {  });
+     cy.visit('/Sandbox/Bootstrap4');
+     cy.waitUntilSkeletonDisappear();
+
      cy.get('#grw-subnav-container').within(() => {
+      cy.getByTestid('open-page-item-control-btn').should('be.visible');
       cy.getByTestid('open-page-item-control-btn').within(() => {
-        cy.getByTestid('open-page-item-control-btn').should('be.visible');
         cy.get('button.btn-page-item-control').click({force: true});
+        cy.getByTestid('page-item-control-menu').should('be.visible');
+        cy.getByTestid('open-page-accessories-modal-btn-with-attachment-data-tab').click();
       });
-       cy.getByTestid('open-page-accessories-modal-btn-with-attachment-data-tab').click();
     });
 
      cy.getByTestid('page-accessories-modal').should('be.visible')
      cy.getByTestid('page-attachment').should('be.visible')
      cy.screenshot(`${ssPrefix}-open-page-attachment-data-bootstrap4`);
   });
+
   it('Share Link Management is shown successfully', () => {
-    cy.visit('/Sandbox/Bootstrap4', { });
+    cy.visit('/Sandbox/Bootstrap4');
+    cy.waitUntilSkeletonDisappear();
+
     cy.get('#grw-subnav-container').within(() => {
       cy.getByTestid('open-page-item-control-btn').within(() => {
         cy.get('button.btn-page-item-control').click({force: true});
@@ -222,10 +259,9 @@ context('Page Accessories Modal', () => {
    cy.getByTestid('share-link-management').should('be.visible');
    cy.screenshot(`${ssPrefix}-open-share-link-management-bootstrap4`);
   });
-
 });
 
-context('Tag Oprations', () =>{
+context('Tag Oprations', { scrollBehavior: false }, () =>{
 
   beforeEach(() => {
     // login
@@ -238,6 +274,7 @@ context('Tag Oprations', () =>{
   it('Successfully add new tag', () => {
     const ssPrefix = 'tag-operations-add-new-tag-'
     const tag = 'we';
+
     cy.visit('/Sandbox');
     cy.waitUntilSkeletonDisappear();
 
@@ -258,7 +295,7 @@ context('Tag Oprations', () =>{
       cy.get('#tag-typeahead-asynctypeahead').should('be.visible');
       cy.get('#tag-typeahead-asynctypeahead-item-0').should('be.visible');
       cy.get('a#tag-typeahead-asynctypeahead-item-0').click({force: true})
-      cy.screenshot(`${ssPrefix}3-insert-tag-name`, {capture: 'viewport'});
+      cy.screenshot(`${ssPrefix}3-insert-tag-name`);
     });
 
     cy.get('#edit-tag-modal').within(() => {
@@ -266,17 +303,17 @@ context('Tag Oprations', () =>{
     });
 
     cy.get('.toast').should('be.visible').trigger('mouseover');
-    cy.get('.grw-taglabels-container > .grw-tag-labels > a', { timeout: 10000 }).contains(tag).should('exist');
+    cy.get('.grw-taglabels-container > .grw-tag-labels > a').contains(tag).should('exist');
     /* eslint-disable cypress/no-unnecessary-waiting */
     cy.wait(150); // wait for toastr to change its color occured by mouseover
-    cy.screenshot(`${ssPrefix}4-click-done`, {capture: 'viewport'});
-
+    cy.screenshot(`${ssPrefix}4-click-done`);
   });
 
   it('Successfully duplicate page by generated tag', () => {
     const ssPrefix = 'tag-operations-page-duplicate-';
     const tag = 'we';
     const newPageName = 'our';
+
     cy.visit('/Sandbox');
     cy.waitUntilSkeletonDisappear();
 
@@ -287,13 +324,16 @@ context('Tag Oprations', () =>{
         });
       });
     });
+
+    // Search result page
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
-    // cy.get('#wiki').should('be.visible');
+    cy.get('#revision-loader').should('be.visible');
+
     // force to add 'active' to pass VRT: https://github.com/weseek/growi/pull/6603
     cy.getByTestid('page-list-item-L').first().invoke('addClass', 'active');
-    cy.screenshot(`${ssPrefix}1-click-tag-name`, {capture: 'viewport'});
+    cy.screenshot(`${ssPrefix}1-click-tag-name`);
     cy.getByTestid('search-result-list').should('be.visible').then(($el)=>{
       cy.wrap($el).within(()=>{
         cy.getByTestid('open-page-item-control-btn').first().click();
@@ -301,7 +341,7 @@ context('Tag Oprations', () =>{
 
       // eslint-disable-next-line cypress/no-unnecessary-waiting
       cy.wait(1500); // for wait rendering pagelist info
-      cy.screenshot(`${ssPrefix}2-click-three-dots-menu`, {capture: 'viewport'});
+      cy.screenshot(`${ssPrefix}2-click-three-dots-menu`);
 
       cy.wrap($el).within(()=>{
         cy.getByTestid('open-page-item-control-btn').first().within(()=>{
@@ -312,14 +352,15 @@ context('Tag Oprations', () =>{
 
     cy.getByTestid('page-duplicate-modal').should('be.visible').within(() => {
       cy.get('.rbt-input-main').type(`-${newPageName}`, {force: true});
-    }).screenshot(`${ssPrefix}3-duplicate-page`, {capture: 'viewport'});
+    }).screenshot(`${ssPrefix}3-duplicate-page`);
 
     cy.getByTestid('page-duplicate-modal').within(() => {
       cy.get('.modal-footer > button.btn').click();
     });
+
     cy.visit(`Sandbox-${newPageName}`);
     cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}4-duplicated-page`, {capture: 'viewport'});
+    cy.screenshot(`${ssPrefix}4-duplicated-page`);
   });
 
   it('Successfully rename page from generated tag', () => {
@@ -330,14 +371,17 @@ context('Tag Oprations', () =>{
 
     cy.visit('/Sandbox-our');
     cy.waitUntilSkeletonDisappear();
+
+    // Search result page
     cy.get('.grw-tag-label').should('be.visible').contains(tag).click();
-    cy.waitUntilSkeletonDisappear();
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
+    cy.get('#revision-loader').should('be.visible');
+
     // eslint-disable-next-line cypress/no-unnecessary-waiting
     cy.wait(300);
-    cy.screenshot(`${ssPrefix}1-click-tag-name`, {capture: 'viewport'});
+    cy.screenshot(`${ssPrefix}1-click-tag-name`);
 
     cy.getByTestid('search-result-list').within(() => {
       cy.get('.list-group-item').each(($row) => {
@@ -376,11 +420,10 @@ context('Tag Oprations', () =>{
       cy.get('.modal-footer > button').click();
     });
 
-    cy.visit(`${newPageName}`);
+    cy.visit(newPageName);
     cy.waitUntilSkeletonDisappear();
 
     cy.getByTestid('grw-tag-labels').should('be.visible')
-    cy.screenshot(`${ssPrefix}4-new-page-name-applied`, {capture: 'viewport'});
+    cy.screenshot(`${ssPrefix}4-new-page-name-applied`);
   });
-
 });

+ 3 - 0
packages/app/test/cypress/integration/40-admin/access-to-admin-page.spec.ts

@@ -39,6 +39,8 @@ context('Access to Admin page', () => {
   it('/admin/security is successfully loaded', () => {
     cy.visit('/admin/security');
     cy.getByTestid('admin-security').should('be.visible');
+    cy.get('#isShowRestrictedByOwner').should('be.checked')
+    cy.get('#isShowRestrictedByGroup').should('be.checked')
     cy.screenshot(`${ssPrefix}-admin-security`);
   });
 
@@ -104,6 +106,7 @@ context('Access to Admin page', () => {
   it('/admin/user-groups is successfully loaded', () => {
     cy.visit('/admin/user-groups');
     cy.getByTestid('admin-user-groups').should('be.visible');
+    cy.getByTestid('grw-user-group-table').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin-user-groups`);
   });
 

+ 234 - 176
packages/app/test/cypress/integration/50-sidebar/access-to-side-bar.spec.ts

@@ -1,191 +1,249 @@
-context('Access to sidebar', () => {
+describe('Access to sidebar', () => {
   const ssPrefix = 'access-to-sidebar-';
 
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-    // collapse sidebar
-    cy.collapseSidebar(false);
-  });
-
-  it('Successfully show/collapse sidebar', () => {
-    cy.visit('/');
-    cy.screenshot(`${ssPrefix}-1-sidebar-shown`, {capture: 'viewport'});
-    cy.getByTestid('grw-navigation-resize-button').click({force: true});
-    cy.screenshot(`${ssPrefix}-2-sidebar-collapsed`, {capture: 'viewport'});
-
-  });
-  it('Successfully access recent changes side bar ', () => {
-    cy.visit('/');
-    cy.getByTestid('grw-sidebar-nav-primary-recent-changes').click();
-    cy.getByTestid('grw-contextual-navigation-sub').then(($el) => {
-      if($el.hasClass('d-none')){
-        cy.getByTestid('grw-navigation-resize-button').click({force: true});
-      }
-    });
-
-    cy.getByTestid('grw-recent-changes').should('be.visible');
-    cy.get('.list-group-item').should('be.visible');
-
-    cy.scrollTo('top');
-    cy.screenshot(`${ssPrefix}recent-changes-1-page-list`);
-
-    cy.get('#grw-sidebar-contents-wrapper').within(() => {
-      cy.get('#recentChangesResize').click({force: true});
-      cy.get('.list-group-item').should('be.visible');
-    });
-
-    cy.scrollTo('top');
-    cy.screenshot(`${ssPrefix}recent-changes-2-switch-sidebar-size`);
-  });
-
-  it('Successfully create a custom sidebar page', () => {
-    cy.visit('/');
-    cy.getByTestid('grw-sidebar-nav-primary-custom-sidebar').click();
-    cy.getByTestid('grw-contextual-navigation-sub').then(($el) => {
-      if($el.hasClass('d-none')){
-        cy.getByTestid('grw-navigation-resize-button').click({force: true});
-      }
-    });
-
-    cy.getByTestid('grw-contextual-navigation-sub').screenshot(`${ssPrefix}custom-sidebar-1-click-on-custom-sidebar`);
-
-    // create /Sidebar contents
-    const content = '# HELLO \n ## Hello\n ### Hello';
-    cy.get('.grw-sidebar-content-header.h5').find('a').click();
-    cy.get('.CodeMirror textarea').type(content, {force: true});
-    cy.screenshot(`${ssPrefix}custom-sidebar-2-custom-sidebar-editor`);
-    cy.getByTestid('save-page-btn').click();
-    cy.get('.layout-root', { timeout: 10000 }).should('not.have.class', 'editing');
-
-    // What to do when UserUISettings is not saved in time
-    cy.getByTestid('grw-sidebar-nav-primary-custom-sidebar').then(($el) => {
-      if (!$el.hasClass('active')) {
-        cy.wrap($el).click();
-      }
-    });
-
-    cy.get('.grw-custom-sidebar-content').should('be.visible');
-    cy.getByTestid('grw-contextual-navigation-sub').screenshot(`${ssPrefix}custom-sidebar-3-custom-sidebar-created`);
-  });
-
-  it('Successfully performed page operation from "page tree"', () => {
-    cy.visit('/');
-    cy.getByTestid('grw-sidebar-nav-primary-page-tree').click();
-    cy.getByTestid('grw-contextual-navigation-sub').then(($el) => {
-      if($el.hasClass('d-none')){
-        cy.getByTestid('grw-navigation-resize-button').click({force: true});
-      }
-    });
-
-    cy.getByTestid('grw-contextual-navigation-sub').should('be.visible')
-    cy.get('.grw-pagetree-item-children').eq(0).should('be.visible');
-    cy.screenshot(`${ssPrefix}page-tree-1-access-to-page-tree`);
-
-    cy.getByTestid('grw-contextual-navigation-sub').screenshot(`${ssPrefix}page-tree-2-hide-page-tree-item`);
-    cy.get('.grw-pagetree-triangle-btn').eq(0).click();
-
-    cy.get('.grw-pagetree-item-children').eq(0).within(() => {
-      cy.getByTestid('open-page-item-control-btn').find('button').eq(0).invoke('css','display','block').click();
-    });
-
-    cy.screenshot(`${ssPrefix}page-tree-3-click-three-dots-menu`);
-    cy.get('.dropdown-menu.show').should('be.visible').within(() => {
-      cy.getByTestid('add-remove-bookmark-btn').click();
-    });
-    cy.screenshot(`${ssPrefix}page-tree-4-add-bookmark`);
-
-
-    cy.get('.grw-pagetree-item-children').eq(0).within(() => {
-      cy.getByTestid('open-page-item-control-btn').find('button').eq(0).invoke('css','display','block').click();
-    });
-    cy.get('.dropdown-menu.show').should('be.visible').within(() => {
-      cy.getByTestid('open-page-duplicate-modal-btn').click();
-    });
-
-    cy.getByTestid('page-duplicate-modal').should('be.visible').within(() => {
-      cy.get('.rbt-input-main').type('_test');
-      cy.screenshot(`${ssPrefix}page-tree-5-duplicate-page`);
-      cy.get('.modal-header > button').click();
-    });
-
-    cy.get('.grw-pagetree-item-children').eq(0).within(() => {
-      cy.getByTestid('open-page-item-control-btn').find('button').eq(0).invoke('css','display','block').click()
-    });
-    cy.get('.dropdown-menu.show').should('be.visible').within(() => {
-      cy.getByTestid('open-page-move-rename-modal-btn').click();
-    });
-
-    cy.get('.grw-pagetree-item-children').eq(0).within(() => {
-      cy.getByTestid('closable-text-input').type('_newname');
-    });
-
-    cy.getByTestid('grw-contextual-navigation-sub').screenshot(`${ssPrefix}page-tree-6-rename-page`);
-    cy.get('body').click(0,0);
-
-    cy.get('.grw-pagetree-item-children').eq(0).within(() => {
-      cy.getByTestid('open-page-item-control-btn').find('button').eq(0).invoke('css','display','block').click()
-    });
-    cy.get('.dropdown-menu.show').should('be.visible').within(() => {
-      cy.getByTestid('open-page-delete-modal-btn').click();
+  context('when logged in', () => {
+    beforeEach(() => {
+      // login
+      cy.fixture("user-admin.json").then(user => {
+        cy.login(user.username, user.password);
+      });
     });
 
-    cy.getByTestid('page-delete-modal').should('be.visible').within(() => {
-      cy.screenshot(`${ssPrefix}page-tree-7-delete-page`);
-      cy.get('.modal-header > button').click();
-    });
+    context('when access to root page', { scrollBehavior: false }, () => {
+      beforeEach(() => {
+        cy.visit('/');
+        cy.waitUntilSkeletonDisappear();
+        cy.collapseSidebar(false);
+      });
 
-  });
+      describe('Test show/collapse button', () => {
+        it('Successfully show sidebar', () => {
+          cy.get('.grw-pagetree').should('be.visible');
+          cy.screenshot(`${ssPrefix}1-sidebar-shown`, {
+            capture: 'viewport',
+            // Blackout for recalculation of toc content hight
+            blackout: ['.grw-side-contents-container', '[data-hide-in-vrt=true]'],
+          });
+        });
+
+        it('Successfully collapse sidebar', () => {
+          cy.getByTestid('grw-navigation-resize-button').click({force: true});
+          cy.screenshot(`${ssPrefix}2-sidebar-collapsed`, {
+            capture: 'viewport',
+            // Blackout for recalculation of toc content hight
+            blackout: ['.grw-side-contents-container', '[data-hide-in-vrt=true]'],
+          });
+        });
+      });
 
-  it('Successfully performed page operation from "Tags" ', () => {
-    cy.visit('/');
-    cy.getByTestid('grw-sidebar-nav-primary-tags').click();
-    cy.getByTestid('grw-contextual-navigation-sub').then(($el) => {
-      if($el.hasClass('d-none')){
-        cy.getByTestid('grw-navigation-resize-button').click({force: true});
-      }
-    });
-    cy.getByTestid('grw-contextual-navigation-sub').screenshot(`${ssPrefix}tags-1-access-to-tags`);
+      describe('Test page tree tab', () => {
+        it('Successfully access to page tree', () => {
+          cy.getByTestid('grw-contextual-navigation-sub').within(() => {
+            cy.get('.grw-pagetree').should('be.visible');
+            cy.screenshot(`${ssPrefix}page-tree-1-access-to-page-tree`);
+          });
+        });
+
+        it('Successfully hide page tree items', () => {
+          cy.getByTestid('grw-contextual-navigation-sub').within(() => {
+            cy.get('.grw-pagetree-open').should('be.visible');
+
+            // hide page tree tiems
+            cy.get('.grw-pagetree-triangle-btn').eq(0).click();
+            cy.screenshot(`${ssPrefix}page-tree-2-hide-page-tree-items`);
+          });
+        });
+
+        it('Successfully click Add to Bookmarks button', () => {
+          // click three dots
+          cy.get('.grw-pagetree-item-children').eq(0).within(() => {
+            cy.getByTestid('open-page-item-control-btn').find('button').eq(0).invoke('css','display','block').click();
+          });
+
+          cy.getByTestid('page-item-control-menu').should('have.class', 'show');
+          cy.screenshot(`${ssPrefix}page-tree-3-before-click-button`, {
+            // Blackout for recalculation of toc content hight
+            blackout: ['.grw-side-contents-container', '[data-hide-in-vrt=true]'],
+          });
+
+          // click add remove bookmark btn
+          cy.getByTestid('page-item-control-menu').should('have.class', 'show').within(() => {
+            cy.getByTestid('add-remove-bookmark-btn').click();
+          });
+
+          // show dropdown again
+          cy.get('.grw-pagetree-item-children').eq(0).within(() => {
+            cy.getByTestid('open-page-item-control-btn').find('button').eq(0).invoke('css','display','block').click();
+          });
+
+          cy.getByTestid('page-item-control-menu').should('have.class', 'show');
+          cy.screenshot(`${ssPrefix}page-tree-4-after-click-button`, {
+            // Blackout for recalculation of toc content hight
+            blackout: ['.grw-side-contents-container', '[data-hide-in-vrt=true]'],
+          });
+        });
+
+        it('Successfully show duplicate page modal', () => {
+          cy.get('.grw-pagetree-item-children').eq(0).within(() => {
+            cy.getByTestid('open-page-item-control-btn').find('button').eq(0).invoke('css','display','block').click();
+          });
+          cy.get('.dropdown-menu.show').should('be.visible').within(() => {
+            cy.getByTestid('open-page-duplicate-modal-btn').click();
+          });
+          cy.getByTestid('page-duplicate-modal').should('be.visible').within(() => {
+            cy.get('.rbt-input-main').type('_test');
+            cy.screenshot(`${ssPrefix}page-tree-5-duplicate-page-modal`);
+            cy.get('.modal-header > button').click();
+          });
+        });
+
+        it('Successfully rename page', () => {
+          cy.getByTestid('grw-contextual-navigation-sub').within(() => {
+            cy.get('.grw-pagetree-item-children').eq(0).within(() => {
+              cy.getByTestid('open-page-item-control-btn').find('button').eq(0).invoke('css','display','block').click()
+            });
+            cy.get('.dropdown-menu.show').should('be.visible').within(() => {
+              cy.getByTestid('open-page-move-rename-modal-btn').click();
+            });
+            cy.get('.grw-pagetree-item-children').eq(0).within(() => {
+              cy.getByTestid('closable-text-input').type('_newname');
+            });
+            cy.screenshot(`${ssPrefix}page-tree-6-rename-page`);
+          });
+        });
+
+        it('Successfully show delete page modal', () => {
+          cy.getByTestid('grw-contextual-navigation-sub').within(() => {
+            cy.get('.grw-pagetree-item-children').eq(0).within(() => {
+              cy.getByTestid('open-page-item-control-btn').find('button').eq(0).invoke('css','display','block').click()
+            });
+            cy.get('.dropdown-menu.show').should('be.visible').within(() => {
+              cy.getByTestid('open-page-delete-modal-btn').click();
+            });
+          });
+          cy.getByTestid('page-delete-modal').should('be.visible').within(() => {
+            cy.screenshot(`${ssPrefix}page-tree-7-delete-page-modal`);
+            cy.get('.modal-header > button').click();
+          });
+        });
+      });
 
-    cy.get('.grw-container-convertible > div > .btn-primary').click({force: true});
+      describe('Test custom sidebar tab', () => {
+        it('Successfully access to custom sidebar', () => {
+          cy.getByTestid('grw-sidebar-nav-primary-custom-sidebar').click();
+
+          // eslint-disable-next-line cypress/no-unnecessary-waiting
+          cy.wait(1500); // Wait debounce for UserUISettings update
+
+          cy.getByTestid('grw-contextual-navigation-sub').within(() => {
+            cy.get('.grw-sidebar-content-header.h5').find('a');
+            cy.screenshot(`${ssPrefix}custom-sidebar-1-access-to-custom-sidebar`);
+          });
+        });
+
+        it('Successfully redirect to editor', () => {
+          const content = '# HELLO \n ## Hello\n ### Hello';
+
+          cy.get('.grw-sidebar-content-header.h5').find('a').click();
+          cy.get('.CodeMirror textarea').type(content, {force: true});
+          cy.screenshot(`${ssPrefix}custom-sidebar-2-redirect-to-editor`);
+          cy.getByTestid('save-page-btn').click();
+          cy.get('.layout-root').should('not.have.class', 'editing');
+        });
+
+        it('Successfully create custom sidebar content', () => {
+          cy.getByTestid('grw-contextual-navigation-sub').within(() => {
+            cy.get('.grw-custom-sidebar-content').should('be.visible');
+            cy.screenshot(`${ssPrefix}custom-sidebar-3-content-created`);
+          });
+        });
+      });
 
-    // collapse sidebar
-    cy.collapseSidebar(true);
+      describe('Test recent changes tab', () => {
+        it('Successfully access to recent changes', () => {
+          cy.getByTestid('grw-sidebar-nav-primary-recent-changes').click();
+
+          // eslint-disable-next-line cypress/no-unnecessary-waiting
+          cy.wait(1500); // Wait debounce for UserUISettings update
+
+          cy.getByTestid('grw-recent-changes').should('be.visible');
+          cy.get('.list-group-item').should('be.visible');
+
+          // The scope of the capture is not narrowed because the blackout is shifted
+          cy.screenshot(`${ssPrefix}recent-changes-1-access-to-recent-changes`, {
+            // Blackout for recalculation of toc content hight
+            blackout: ['.grw-side-contents-container', '[data-hide-in-vrt=true]'],
+          });
+        });
+
+        it('Successfully switch content size', () => {
+          cy.get('#grw-sidebar-contents-wrapper').within(() => {
+            cy.get('#recentChangesResize').click({force: true});
+            cy.get('.list-group-item').should('be.visible');
+          });
+
+          // The scope of the capture is not narrowed because the blackout is shifted
+          cy.screenshot(`${ssPrefix}recent-changes-2-switch-content-size`, {
+            // Blackout for recalculation of toc content hight
+            blackout: ['.grw-side-contents-container', '[data-hide-in-vrt=true]'],
+          });
+        });
+      });
 
-    cy.screenshot(`${ssPrefix}tags-2-check-all-tags`);
-  });
+      describe('Test tags tab', () => {
+        it('Successfully access to tags', () => {
+          cy.getByTestid('grw-sidebar-nav-primary-tags').click();
+
+          // eslint-disable-next-line cypress/no-unnecessary-waiting
+          cy.wait(1500); // Wait debounce for UserUISettings update
+
+          cy.getByTestid('grw-contextual-navigation-sub').within(() => {
+            cy.getByTestid('grw-tags-list').should('be.visible');
+            cy.screenshot(`${ssPrefix}tags-1-access-to-tags`);
+          });
+        });
+
+        it('Succesfully click all tags button', () => {
+          cy.get('.grw-container-convertible > div > .btn-primary').click({force: true});
+          cy.collapseSidebar(true);
+          cy.getByTestid('grw-tags-list').should('be.visible');
+          cy.screenshot(`${ssPrefix}tags-2-click-all-tags-button`);
+        });
+      });
 
-  // it('Successfully access to My Drafts page', () => {
-  //   cy.visit('/');
-  //   cy.collapseSidebar(true);
-  //   cy.get('.grw-sidebar-nav-secondary-container').within(() => {
-  //     cy.get('a[href*="/me/drafts"]').click();
-  //   });
-  //   cy.screenshot(`${ssPrefix}access-to-drafts-page`);
-  // });
-  it('Successfully access to GROWI Docs page', () => {
-    cy.visit('/');
-    cy.get('.grw-sidebar-nav-secondary-container').within(() => {
-      cy.get('a[href*="https://docs.growi.org"]').then(($a) => {
-        const url = $a.prop('href')
-        cy.request(url).its('body').should('include', '</html>');
+      // TODO: No Drafts pages on GROWI version 6
+      // it('Successfully access to My Drafts page', () => {
+      //   cy.visit('/');
+      //   cy.collapseSidebar(true);
+      //   cy.get('.grw-sidebar-nav-secondary-container').within(() => {
+      //     cy.get('a[href*="/me/drafts"]').click();
+      //   });
+      //   cy.screenshot(`${ssPrefix}access-to-drafts-page`);
+      // });
+
+      describe('Test access to GROWI Docs page', () => {
+        it('Successfully access to GROWI Docs page', () => {
+          cy.get('.grw-sidebar-nav-secondary-container').within(() => {
+            cy.get('a[href*="https://docs.growi.org"]').then(($a) => {
+              const url = $a.prop('href')
+              cy.request(url).its('body').should('include', '</html>');
+            });
+          });
+        });
       });
-    });
-  });
 
-  it('Successfully access to trash page', () => {
-    cy.visit('/');
-    cy.collapseSidebar(true);
-    cy.get('.grw-sidebar-nav-secondary-container').within(() => {
-      cy.get('a[href*="/trash"]').click();
+      describe('Test access to trash page', () => {
+        it('Successfully access to trash page', () => {
+          cy.collapseSidebar(true);
+          cy.get('.grw-sidebar-nav-secondary-container').within(() => {
+            cy.get('a[href*="/trash"]').click();
+          });
+
+          cy.get('.grw-page-path-hierarchical-link').should('be.visible');
+          cy.get('.grw-custom-nav-tab').should('be.visible');
+          cy.screenshot(`${ssPrefix}access-to-trash-page`);
+        });
+      });
     });
-
-    cy.get('.grw-page-path-hierarchical-link').should('be.visible');
-
-    cy.get('.grw-custom-nav-tab').should('be.visible');
-
-    cy.screenshot(`${ssPrefix}access-to-trash-page`);
   });
 });

+ 8 - 4
packages/app/test/cypress/integration/50-sidebar/switching-sidebar-mode.spec.ts

@@ -25,14 +25,18 @@ context('Switch sidebar mode', () => {
     cy.get('.grw-apperance-mode-dropdown').first().click();
 
     cy.get('[for="swSidebarMode"]').click({force: true});
+    cy.get('.grw-sidebar-nav').should('not.be.visible');
     cy.screenshot(`${ssPrefix}-switch-sidebar-mode`, {
-      blackout: ['#revision-toc', '[data-hide-in-vrt=true]'],
-    })
+      // Blackout for recalculation of toc content hight
+      blackout: ['.grw-side-contents-container', '[data-hide-in-vrt=true]'],
+    });
 
     cy.get('[for="swSidebarMode"]').click({force: true});
+    cy.get('.grw-sidebar-nav').should('be.visible');
     cy.screenshot(`${ssPrefix}-switch-sidebar-mode-back`, {
-      blackout: ['#revision-toc', '[data-hide-in-vrt=true]'],
-    })
+      // Blackout for recalculation of toc content hight
+      blackout: ['.grw-side-contents-container','[data-hide-in-vrt=true]'],
+    });
   });
 
 });

+ 1 - 1
packages/app/tsconfig.build.client.json

@@ -8,7 +8,7 @@
     "paths": {
       "~/*": ["./src/*"],
       "^/*": ["./*"],
-      "@growi/plugin-lsx/*": ["../plugin-lsx/src/*"],
+      "@growi/remark-lsx/*": ["../remark-lsx/src/*"],
       "@growi/*": ["../*/src"],
       "debug": ["./src/server/utils/logger/alias-for-debug"]
     }

+ 1 - 1
packages/app/tsconfig.build.server.json

@@ -11,7 +11,7 @@
     "paths": {
       "~/*": ["./src/*"],
       "^/*": ["./*"],
-      "@growi/plugin-lsx/*": ["../plugin-lsx/dist/cjs/*"],
+      "@growi/remark-lsx/*": ["../remark-lsx/dist/cjs/*"],
       "debug": ["./src/utils/logger/alias-for-debug"]
     }
   },

+ 1 - 1
packages/app/tsconfig.json

@@ -5,7 +5,7 @@
     "paths": {
       "~/*": ["./src/*"],
       "^/*": ["./*"],
-      "@growi/plugin-lsx/*": ["../plugin-lsx/src/*"],
+      "@growi/remark-lsx/*": ["../remark-lsx/src/*"],
       "@growi/*": ["../*/src"],
       "debug": ["./src/server/utils/logger/alias-for-debug"]
     }

+ 0 - 1
packages/core/src/index.ts

@@ -22,7 +22,6 @@ export * from './interfaces/revision';
 export * from './interfaces/subscription';
 export * from './interfaces/tag';
 export * from './interfaces/user';
-export * from './plugin/service/tag-cache-manager';
 export * from './models/devided-page-path';
 export * from './models/vo/error-apiv3';
 export * from './service/localstorage-manager';

+ 0 - 69
packages/core/src/plugin/service/tag-cache-manager.js

@@ -1,69 +0,0 @@
-import { LocalStorageManager } from '../../service/localstorage-manager';
-
-/**
- * Service Class for caching React state and TagContext
- */
-export class TagCacheManager {
-
-  /**
-   * @callback generateCacheKey
-   * @param {TagContext} tagContext - TagContext instance
-   * @returns {string} Cache key from TagContext
-   *
-   */
-
-  /**
-   * Constructor
-   * @param {string} cacheNs Used as LocalStorageManager namespace
-   * @param {generateCacheKey} generateCacheKey
-   */
-  constructor(cacheNs, generateCacheKey) {
-    if (cacheNs == null) {
-      throw new Error('args \'cacheNs\' is required.');
-    }
-    if (generateCacheKey == null) {
-      throw new Error('args \'generateCacheKey\' is required.');
-    }
-    if (typeof generateCacheKey !== 'function') {
-      throw new Error('args \'generateCacheKey\' should be function.');
-    }
-
-    this.cacheNs = cacheNs;
-    this.generateCacheKey = generateCacheKey;
-  }
-
-  /**
-   * Retrieve state cache object from local storage
-   * @param {TagContext} tagContext
-   * @returns {object} a cache object that correspont to the specified `tagContext`
-   */
-  getStateCache(tagContext) {
-    const localStorageManager = LocalStorageManager.getInstance();
-
-    const key = this.generateCacheKey(tagContext);
-    const stateCache = localStorageManager.retrieveFromSessionStorage(this.cacheNs, key);
-
-    return stateCache;
-  }
-
-  /**
-   * store state object of React Component with specified key
-   *
-   * @param {TagContext} tagContext
-   * @param {object} state state object of React Component
-   */
-  cacheState(tagContext, state) {
-    const localStorageManager = LocalStorageManager.getInstance();
-    const key = this.generateCacheKey(tagContext);
-    localStorageManager.saveToSessionStorage(this.cacheNs, key, state);
-  }
-
-  /**
-   * clear all state caches
-   */
-  clearAllStateCaches() {
-    const localStorageManager = LocalStorageManager.getInstance();
-    localStorageManager.clearAllStateCaches(this.cacheNs);
-  }
-
-}

+ 0 - 125
packages/core/src/test/plugin/service/tag-cache-manager.test.js

@@ -1,125 +0,0 @@
-/* eslint-disable import/first */
-
-// import each from 'jest-each';
-jest.mock('~/service/localstorage-manager');
-
-import { TagCacheManager } from '~/plugin/service/tag-cache-manager';
-import { LocalStorageManager } from '~/service/localstorage-manager';
-/* eslint-enable import/first */
-
-describe('TagCacheManager.constructor', () => {
-
-  test('throws Exception when \'cacheNs\' is null', () => {
-    const generateCacheKeyMock = jest.fn();
-
-    expect(() => {
-      // eslint-disable-next-line no-new
-      new TagCacheManager(null, generateCacheKeyMock);
-    }).toThrowError(/cacheNs/);
-  });
-
-  test('throws Exception when \'generateCacheKey\' is null', () => {
-    expect(() => {
-      // eslint-disable-next-line no-new
-      new TagCacheManager('dummy ns', null);
-    }).toThrowError(/generateCacheKey/);
-  });
-
-  test('throws Exception when \'generateCacheKey\' is not function', () => {
-    expect(() => {
-      // eslint-disable-next-line no-new
-      new TagCacheManager('dummy ns', {});
-    }).toThrowError(/generateCacheKey/);
-  });
-
-  test('set params', () => {
-    const generateCacheKeyMock = jest.fn();
-
-    const instance = new TagCacheManager('dummy ns', generateCacheKeyMock);
-    expect(instance).not.toBeNull();
-    expect(instance.cacheNs).toBe('dummy ns');
-    expect(instance.generateCacheKey).toBe(generateCacheKeyMock);
-  });
-
-});
-
-describe('TagCacheManager', () => {
-
-  let generateCacheKeyMock = null;
-  let localStorageManagerMock = null;
-
-  let tagCacheManager = null;
-
-
-  beforeEach(() => {
-    generateCacheKeyMock = jest.fn();
-    localStorageManagerMock = jest.fn();
-
-    // mock for LocalStorageManager.getInstance
-    LocalStorageManager.getInstance = jest.fn();
-    LocalStorageManager.getInstance.mockReturnValue(localStorageManagerMock);
-
-    tagCacheManager = new TagCacheManager('dummy ns', generateCacheKeyMock);
-  });
-
-  test('.getStateCache', () => {
-    // partial mock
-    tagCacheManager.generateCacheKey = jest.fn().mockReturnValue('dummy key');
-
-    // mock for LocalStorageManager
-    const stateCacheMock = jest.fn();
-    localStorageManagerMock.retrieveFromSessionStorage = jest.fn();
-    localStorageManagerMock.retrieveFromSessionStorage
-      .mockReturnValue(stateCacheMock);
-
-    const tagContextMock = jest.fn();
-
-    // when
-    const result = tagCacheManager.getStateCache(tagContextMock);
-    // then
-    expect(result).not.toBeNull();
-    expect(result).toBe(stateCacheMock);
-    const generateCacheKeyMockCalls = tagCacheManager.generateCacheKey.mock.calls;
-    expect(generateCacheKeyMockCalls.length).toBe(1);
-    expect(generateCacheKeyMockCalls[0][0]).toBe(tagContextMock);
-    const retrieveFromSessionStorageMockCalls = localStorageManagerMock.retrieveFromSessionStorage.mock.calls;
-    expect(retrieveFromSessionStorageMockCalls.length).toBe(1);
-    expect(retrieveFromSessionStorageMockCalls[0][0]).toBe('dummy ns');
-    expect(retrieveFromSessionStorageMockCalls[0][1]).toBe('dummy key');
-  });
-
-  test('.getStateCache with state object', () => {
-    // partial mock
-    tagCacheManager.generateCacheKey = jest.fn().mockReturnValue('dummy key');
-
-    // mock for LocalStorageManager
-    localStorageManagerMock.saveToSessionStorage = jest.fn();
-
-    const tagContextMock = jest.fn();
-    const stateMock = jest.fn();
-
-    // when
-    tagCacheManager.cacheState(tagContextMock, stateMock);
-    // then
-    const generateCacheKeyMockCalls = tagCacheManager.generateCacheKey.mock.calls;
-    expect(generateCacheKeyMockCalls.length).toBe(1);
-    expect(generateCacheKeyMockCalls[0][0]).toBe(tagContextMock);
-    const saveToSessionStorageMockCalls = localStorageManagerMock.saveToSessionStorage.mock.calls;
-    expect(saveToSessionStorageMockCalls.length).toBe(1);
-    expect(saveToSessionStorageMockCalls[0][0]).toBe('dummy ns');
-    expect(saveToSessionStorageMockCalls[0][1]).toBe('dummy key');
-    expect(saveToSessionStorageMockCalls[0][2]).toBe(stateMock);
-  });
-
-  test('.clearAllStateCaches', () => {
-    // mock for LocalStorageManager
-    localStorageManagerMock.clearAllStateCaches = jest.fn();
-
-    // when
-    tagCacheManager.clearAllStateCaches();
-    // then
-    const clearAllStateCachesMockCalls = localStorageManagerMock.clearAllStateCaches.mock.calls;
-    expect(clearAllStateCachesMockCalls.length).toBe(1);
-    expect(clearAllStateCachesMockCalls[0][0]).toBe('dummy ns');
-  });
-});

+ 0 - 265
packages/plugin-lsx/src/components/Lsx.tsx

@@ -1,265 +0,0 @@
-import React, {
-  useCallback, useEffect, useMemo, useState,
-} from 'react';
-
-import * as url from 'url';
-
-import { IPage, pathUtils } from '@growi/core';
-import axios from 'axios';
-
-import { LsxListView } from './LsxPageList/LsxListView';
-import { PageNode } from './PageNode';
-import { LsxContext } from './lsx-context';
-import { getInstance as getTagCacheManager } from './tag-cache-manager';
-
-import styles from './Lsx.module.scss';
-
-
-const tagCacheManager = getTagCacheManager();
-
-
-/**
- * compare whether path1 and path2 is the same
- *
- * @param {string} path1
- * @param {string} path2
- * @returns
- *
- * @memberOf Lsx
- */
-function isEquals(path1: string, path2: string) {
-  return pathUtils.removeTrailingSlash(path1) === pathUtils.removeTrailingSlash(path2);
-}
-
-function getParentPath(path: string) {
-  return pathUtils.addTrailingSlash(decodeURIComponent(url.resolve(path, '../')));
-}
-
-/**
- * generate PageNode instances for target page and the ancestors
- *
- * @param {any} pathToNodeMap
- * @param {any} rootPagePath
- * @param {any} pagePath
- * @returns
- * @memberof Lsx
- */
-function generatePageNode(pathToNodeMap: Record<string, PageNode>, rootPagePath: string, pagePath: string): PageNode | null {
-  // exclude rootPagePath itself
-  if (isEquals(pagePath, rootPagePath)) {
-    return null;
-  }
-
-  // return when already registered
-  if (pathToNodeMap[pagePath] != null) {
-    return pathToNodeMap[pagePath];
-  }
-
-  // generate node
-  const node = new PageNode(pagePath);
-  pathToNodeMap[pagePath] = node;
-
-  /*
-    * process recursively for ancestors
-    */
-  // get or create parent node
-  const parentPath = getParentPath(pagePath);
-  const parentNode = generatePageNode(pathToNodeMap, rootPagePath, parentPath);
-  // associate to patent
-  if (parentNode != null) {
-    parentNode.children.push(node);
-  }
-
-  return node;
-}
-
-function generatePageNodeTree(rootPagePath: string, pages: IPage[]) {
-  const pathToNodeMap: Record<string, PageNode> = {};
-
-  pages.forEach((page) => {
-    // add slash ensure not to forward match to another page
-    // e.g. '/Java/' not to match to '/JavaScript'
-    const pagePath = pathUtils.addTrailingSlash(page.path);
-
-    const node = generatePageNode(pathToNodeMap, rootPagePath, pagePath); // this will not be null
-
-    // exclude rootPagePath itself
-    if (node == null) {
-      return;
-    }
-
-    // set the Page substance
-    node.page = page;
-  });
-
-  // return root objects
-  const rootNodes: PageNode[] = [];
-  Object.keys(pathToNodeMap).forEach((pagePath) => {
-    // exclude '/'
-    if (pagePath === '/') {
-      return;
-    }
-
-    const parentPath = getParentPath(pagePath);
-
-    // pick up what parent doesn't exist
-    if ((parentPath === '/') || !(parentPath in pathToNodeMap)) {
-      rootNodes.push(pathToNodeMap[pagePath]);
-    }
-  });
-  return rootNodes;
-}
-
-
-type Props = {
-  children: React.ReactNode,
-  className?: string,
-
-  prefix: string,
-  num?: string,
-  depth?: string,
-  sort?: string,
-  reverse?: string,
-  filter?: string,
-
-  forceToFetchData?: boolean,
-};
-
-type StateCache = {
-  isError: boolean,
-  errorMessage: string,
-  basisViewersCount?: number,
-  nodeTree?: PageNode[],
-}
-
-export const Lsx = ({
-  prefix,
-  num, depth, sort, reverse, filter,
-  ...props
-}: Props): JSX.Element => {
-
-  const [isLoading, setLoading] = useState(false);
-  const [isError, setError] = useState(false);
-  const [isCacheExists, setCacheExists] = useState(false);
-  const [nodeTree, setNodeTree] = useState<PageNode[]|undefined>();
-  const [basisViewersCount, setBasisViewersCount] = useState<number|undefined>();
-  const [errorMessage, setErrorMessage] = useState('');
-
-  const { forceToFetchData } = props;
-
-  const lsxContext = useMemo(() => {
-    const options = {
-      num, depth, sort, reverse, filter,
-    };
-    return new LsxContext(prefix, options);
-  }, [depth, filter, num, prefix, reverse, sort]);
-
-  const retrieveDataFromCache = useCallback(() => {
-    // get state object cache
-    const stateCache = tagCacheManager.getStateCache(lsxContext) as StateCache | null;
-
-    // instanciate PageNode
-    if (stateCache != null && stateCache.nodeTree != null) {
-      stateCache.nodeTree = stateCache.nodeTree.map((obj) => {
-        return PageNode.instanciateFrom(obj);
-      });
-    }
-
-    return stateCache;
-  }, [lsxContext]);
-
-  const loadData = useCallback(async() => {
-    setLoading(true);
-
-    // add slash ensure not to forward match to another page
-    // ex: '/Java/' not to match to '/JavaScript'
-    const pagePath = pathUtils.addTrailingSlash(lsxContext.pagePath);
-
-    let newNodeTree: PageNode[] = [];
-    try {
-      const result = await axios.get('/_api/plugins/lsx', {
-        params: {
-          pagePath,
-          options: lsxContext.options,
-        },
-      });
-
-      newNodeTree = generatePageNodeTree(pagePath, result.data.pages);
-      setNodeTree(newNodeTree);
-      setBasisViewersCount(result.data.toppageViewersCount);
-      setError(false);
-
-      // store to sessionStorage
-      tagCacheManager.cacheState(lsxContext, {
-        isError: false,
-        errorMessage: '',
-        basisViewersCount,
-        nodeTree: newNodeTree,
-      });
-    }
-    catch (error) {
-      setError(true);
-      setErrorMessage(error.message);
-
-      // store to sessionStorage
-      tagCacheManager.cacheState(lsxContext, {
-        isError: true,
-        errorMessage: error.message,
-      });
-    }
-    finally {
-      setLoading(false);
-    }
-  }, [basisViewersCount, lsxContext]);
-
-  useEffect(() => {
-    // get state object cache
-    const stateCache = retrieveDataFromCache();
-
-    if (stateCache != null) {
-      setCacheExists(true);
-      setNodeTree(stateCache.nodeTree);
-      setError(stateCache.isError);
-      setErrorMessage(stateCache.errorMessage);
-
-      // switch behavior by forceToFetchData
-      if (!forceToFetchData) {
-        return; // go to render()
-      }
-    }
-
-    loadData();
-  }, [forceToFetchData, loadData, retrieveDataFromCache]);
-
-  const renderContents = () => {
-    if (isError) {
-      return (
-        <div className="text-warning">
-          <i className="fa fa-exclamation-triangle fa-fw"></i>
-          {lsxContext.toString()} (-&gt; <small>{errorMessage}</small>)
-        </div>
-      );
-    }
-
-    const showListView = nodeTree != null && (!isLoading || nodeTree.length > 0);
-
-    return (
-      <>
-        { isLoading && (
-          <div className={`text-muted ${isLoading ? 'lsx-blink' : ''}`}>
-            <small>
-              <i className="fa fa-spinner fa-pulse mr-1"></i>
-              {lsxContext.toString()}
-              { isCacheExists && <>&nbsp;(Showing cache..)</> }
-            </small>
-          </div>
-        ) }
-        { showListView && (
-          <LsxListView nodeTree={nodeTree} lsxContext={lsxContext} basisViewersCount={basisViewersCount} />
-        ) }
-      </>
-    );
-  };
-
-  return <div className={`lsx ${styles.lsx}`}>{renderContents()}</div>;
-};

Некоторые файлы не были показаны из-за большого количества измененных файлов