Explorar el Código

Merge branch 'master' into fix/vrt-30-search-all-pages-1-2-3

Taichi Masuyama hace 3 años
padre
commit
0b187eb590
Se han modificado 100 ficheros con 1089 adiciones y 1038 borrados
  1. 2 4
      .github/workflows/ci-app-prod.yml
  2. 3 4
      .github/workflows/ci-app.yml
  3. 5 1
      .github/workflows/reusable-app-prod.yml
  4. 2 6
      .vscode/launch.json
  5. 0 0
      packages-obsolete/plugin-attachment-refs/.eslintignore
  6. 0 0
      packages-obsolete/plugin-attachment-refs/.gitignore
  7. 0 0
      packages-obsolete/plugin-attachment-refs/README.md
  8. 0 0
      packages-obsolete/plugin-attachment-refs/package.json
  9. 0 0
      packages-obsolete/plugin-attachment-refs/src/client-entry.js
  10. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/css/index.css
  11. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/components/AttachmentList.jsx
  12. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/components/ExtractedAttachments.jsx
  13. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/util/GalleryContext.js
  14. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPostRenderInterceptor.js
  15. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/util/Interceptor/RefsPreRenderInterceptor.js
  16. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/util/RefsContext.js
  17. 0 0
      packages-obsolete/plugin-attachment-refs/src/client/js/util/TagCacheManagerFactory.js
  18. 0 0
      packages-obsolete/plugin-attachment-refs/src/index.js
  19. 0 0
      packages-obsolete/plugin-attachment-refs/src/server-entry.js
  20. 0 0
      packages-obsolete/plugin-attachment-refs/src/server/routes/index.js
  21. 0 0
      packages-obsolete/plugin-attachment-refs/src/server/routes/refs.js
  22. 0 0
      packages-obsolete/plugin-attachment-refs/src/utils/logger/index.ts
  23. 0 0
      packages-obsolete/plugin-attachment-refs/tsconfig.base.json
  24. 0 0
      packages-obsolete/plugin-attachment-refs/tsconfig.build.cjs.json
  25. 0 0
      packages-obsolete/plugin-attachment-refs/tsconfig.build.esm.json
  26. 0 0
      packages-obsolete/plugin-attachment-refs/tsconfig.json
  27. 2 2
      packages/app/docker/Dockerfile
  28. 1 0
      packages/app/next.config.js
  29. 4 3
      packages/app/package.json
  30. 2 2
      packages/app/public/static/locales/en_US/admin.json
  31. 0 1
      packages/app/public/static/locales/en_US/translation.json
  32. 2 2
      packages/app/public/static/locales/ja_JP/admin.json
  33. 2 2
      packages/app/public/static/locales/zh_CN/admin.json
  34. 0 1
      packages/app/public/static/locales/zh_CN/translation.json
  35. 1 1
      packages/app/resource/locales/en_US/sandbox-diagrams.md
  36. 1 1
      packages/app/resource/locales/ja_JP/sandbox-diagrams.md
  37. 1 1
      packages/app/resource/locales/zh_CN/sandbox-diagrams.md
  38. 50 37
      packages/app/src/client/services/page-operation.ts
  39. 0 21
      packages/app/src/client/util/editor.ts
  40. 2 2
      packages/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  41. 2 2
      packages/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  42. 0 117
      packages/app/src/components/Drawio.tsx
  43. 5 3
      packages/app/src/components/InstallerForm.tsx
  44. 0 2
      packages/app/src/components/Layout/BasicLayout.tsx
  45. 0 10
      packages/app/src/components/Layout/NoLoginLayout.module.scss
  46. 9 4
      packages/app/src/components/LoginForm.tsx
  47. 6 0
      packages/app/src/components/Page.module.scss
  48. 160 157
      packages/app/src/components/Page.tsx
  49. 4 5
      packages/app/src/components/PageDuplicateModal.tsx
  50. 46 66
      packages/app/src/components/PageEditor.tsx
  51. 30 19
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  52. 1 1
      packages/app/src/components/PageEditor/ConflictDiffModal.tsx
  53. 18 5
      packages/app/src/components/PageEditor/DrawioCommunicationHelper.ts
  54. 3 7
      packages/app/src/components/PageEditor/DrawioModal.tsx
  55. 10 2
      packages/app/src/components/PageEditor/HandsontableModal.tsx
  56. 7 7
      packages/app/src/components/PageEditor/MarkdownDrawioUtil.js
  57. 5 0
      packages/app/src/components/PageEditor/MarkdownTableUtil.js
  58. 38 47
      packages/app/src/components/PageEditorByHackmd.tsx
  59. 6 2
      packages/app/src/components/PageList/PageListItemL.tsx
  60. 12 24
      packages/app/src/components/PageRenameModal.tsx
  61. 19 0
      packages/app/src/components/ReactMarkdownComponents/DrawioViewerWithEditButton.module.scss
  62. 70 0
      packages/app/src/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx
  63. 16 3
      packages/app/src/components/ReactMarkdownComponents/Header.tsx
  64. 18 0
      packages/app/src/components/ReactMarkdownComponents/Table.tsx
  65. 25 0
      packages/app/src/components/ReactMarkdownComponents/TableWithEditButton.module.scss
  66. 52 0
      packages/app/src/components/ReactMarkdownComponents/TableWithEditButton.tsx
  67. 7 2
      packages/app/src/components/SavePageControls.tsx
  68. 38 0
      packages/app/src/components/Script/DrawioViewerScript.tsx
  69. 6 3
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  70. 1 1
      packages/app/src/components/Sidebar/RecentChanges.tsx
  71. 1 0
      packages/app/src/components/TableOfContents.tsx
  72. 0 10
      packages/app/src/interfaces/editor-settings.ts
  73. 8 0
      packages/app/src/interfaces/errors/external-account-login-error.ts
  74. 0 8
      packages/app/src/interfaces/global.ts
  75. 0 11
      packages/app/src/interfaces/graph-viewer.ts
  76. 10 0
      packages/app/src/interfaces/page-operation.ts
  77. 6 0
      packages/app/src/interfaces/rehype.ts
  78. 11 0
      packages/app/src/models/vo/external-account-login-error.ts
  79. 19 7
      packages/app/src/pages/[[...path]].page.tsx
  80. 13 3
      packages/app/src/pages/_document.page.tsx
  81. 10 1
      packages/app/src/pages/_private-legacy-pages.page.tsx
  82. 10 1
      packages/app/src/pages/_search.page.tsx
  83. 10 0
      packages/app/src/pages/login.page.tsx
  84. 98 87
      packages/app/src/pages/share/[[...path]].page.tsx
  85. 15 1
      packages/app/src/pages/tags.page.tsx
  86. 1 1
      packages/app/src/server/crowi/index.js
  87. 5 0
      packages/app/src/server/models/config.ts
  88. 11 13
      packages/app/src/server/routes/index.js
  89. 48 45
      packages/app/src/server/routes/login-passport.js
  90. 1 1
      packages/app/src/server/service/config-loader.ts
  91. 0 38
      packages/app/src/server/views/widget/headers/drawio.html
  92. 0 156
      packages/app/src/services/renderer/interceptor/drawio-interceptor.js
  93. 20 0
      packages/app/src/services/renderer/remark-plugins/table.ts
  94. 25 6
      packages/app/src/services/renderer/renderer.tsx
  95. 2 6
      packages/app/src/stores/context.tsx
  96. 6 1
      packages/app/src/stores/editor.tsx
  97. 2 1
      packages/app/src/stores/middlewares/sync-to-storage.ts
  98. 62 19
      packages/app/src/stores/modal.tsx
  99. 0 41
      packages/app/src/styles/_page.scss
  100. 1 1
      packages/app/src/styles/organisms/_wiki-custom-sidebar.scss

+ 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

+ 5 - 1
.github/workflows/reusable-app-prod.yml

@@ -235,7 +235,11 @@ jobs:
 
     - name: lerna bootstrap
       run: |
-        npx lerna bootstrap -- --frozen-lockfile
+        npx lerna bootstrap -- --production
+
+    - name: lerna add packages needed for CI
+      run: |
+        npx lerna add yargs
 
     - name: Download production files artifact
       uses: actions/download-artifact@v3

+ 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

+ 1 - 0
packages/app/next.config.js

@@ -25,6 +25,7 @@ const setupTranspileModules = () => {
     // listing ESM packages until experimental.esmExternals works correctly to avoid ERR_REQUIRE_ESM
     'react-markdown',
     'unified',
+    'markdown-table',
     'character-entities-html4',
     'comma-separated-tokens',
     'decode-named-character-reference',

+ 4 - 3
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",
@@ -122,6 +123,7 @@
     "is-absolute-url": "^4.0.1",
     "is-iso-date": "^0.0.1",
     "lucene-query-parser": "^1.2.0",
+    "markdown-table": "^1.1.1",
     "md5": "^2.2.1",
     "method-override": "^3.0.0",
     "migrate-mongo": "^8.2.3",
@@ -236,7 +238,6 @@
     "jquery.cookie": "~1.4.1",
     "jshint": "^2.13.0",
     "load-css-file": "^1.0.0",
-    "markdown-table": "^1.1.1",
     "material-icons": "^1.11.3",
     "morgan": "^1.10.0",
     "next-transpile-modules": "^9.0.0",

+ 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": "任何人",

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 1
packages/app/resource/locales/en_US/sandbox-diagrams.md


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 1
packages/app/resource/locales/ja_JP/sandbox-diagrams.md


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 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>
   );
 };

+ 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;

+ 5 - 3
packages/app/src/components/InstallerForm.tsx

@@ -106,11 +106,13 @@ const InstallerForm = memo((): JSX.Element => {
       <div className="row">
         <form role="form" id="register-form" className="col-md-12" onSubmit={submitHandler}>
           <div className="dropdown mb-3">
-            <div className="d-flex dropdown-with-icon">
-              <i className="icon-bubbles border-0 rounded-0" />
+            <div className="input-group">
+              <div className="input-group-prepend dropdown-with-icon">
+                <i className="input-group-text icon-bubbles border-0 rounded-0" />
+              </div>
               <button
                 type="button"
-                className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
+                className="btn btn-secondary dropdown-toggle form-control text-right rounded-right"
                 id="dropdownLanguage"
                 data-testid="dropdownLanguage"
                 data-toggle="dropdown"

+ 0 - 2
packages/app/src/components/Layout/BasicLayout.tsx

@@ -22,7 +22,6 @@ const PageDeleteModal = dynamic(() => import('../PageDeleteModal'), { ssr: false
 const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal'), { ssr: false });
-const DrawioModal = dynamic(() => import('../PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
 // Fab
 const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false });
 
@@ -64,7 +63,6 @@ export const BasicLayout = ({
         <PageDeleteModal />
         <PageRenameModal />
         <PageAccessoriesModal />
-        <DrawioModal />
       </DndProvider>
 
       <PagePresentationModal />

+ 0 - 10
packages/app/src/components/Layout/NoLoginLayout.module.scss

@@ -63,16 +63,6 @@
     }
   }
 
-  .dropdown-with-icon {
-    .dropdown-toggle {
-      @extend .form-control;
-    }
-    i {
-      @extend .input-group-text;
-      margin-right: -1px;
-    }
-  }
-
   .input-group {
     margin-bottom: 10px;
 

+ 9 - 4
packages/app/src/components/LoginForm.tsx

@@ -7,8 +7,9 @@ import { useRouter } from 'next/router';
 import ReactCardFlip from 'react-card-flip';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
+import type { IExternalAccountLoginError } from '~/interfaces/errors/external-account-login-error';
 import { LoginErrorCode } from '~/interfaces/errors/login-error';
-import { IErrorV3 } from '~/interfaces/errors/v3-error';
+import type { IErrorV3 } from '~/interfaces/errors/v3-error';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import { toArrayIfNot } from '~/utils/array-utils';
 
@@ -26,7 +27,8 @@ type LoginFormProps = {
   isLdapStrategySetup: boolean,
   isLdapSetupFailed: boolean,
   objOfIsExternalAuthEnableds?: any,
-  isMailerSetup?: boolean
+  isMailerSetup?: boolean,
+  externalAccountLoginError?: IExternalAccountLoginError,
 }
 export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const { t } = useTranslation();
@@ -129,7 +131,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   }, [t]);
 
   // wrap error elements which do not use dangerouslySetInnerHtml
-  const generateSafelySetErrors = useCallback((errors: IErrorV3[]): JSX.Element => {
+  const generateSafelySetErrors = useCallback((errors: (IErrorV3 | IExternalAccountLoginError)[]): JSX.Element => {
     if (errors == null || errors.length === 0) return <></>;
     return (
       <ul className="alert alert-danger">
@@ -151,7 +153,10 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     // Generate login error elements using dangerouslySetInnerHTML
     const loginErrorElementWithDangerouslySetInnerHTML = generateDangerouslySetErrors(loginErrorListForDangerouslySetInnerHTML);
     // Generate login error elements using <ul>, <li>
-    const loginErrorElement = generateSafelySetErrors(loginErrorList);
+
+    const loginErrorElement = props.externalAccountLoginError != null
+      ? generateSafelySetErrors([...loginErrorList, props.externalAccountLoginError])
+      : generateSafelySetErrors(loginErrorList);
 
     return (
       <>

+ 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>
   );
+
 };

+ 4 - 5
packages/app/src/components/PageDuplicateModal.tsx

@@ -151,7 +151,7 @@ const PageDuplicateModal = (): JSX.Element => {
   }, [isOpened]);
 
 
-  const bodyContent = () => {
+  const renderBodyContent = () => {
     if (!isOpened || page == null) {
       return <></>;
     }
@@ -238,7 +238,7 @@ const PageDuplicateModal = (): JSX.Element => {
     );
   };
 
-  const footerContent = () => {
+  const renderFooterContent = () => {
     if (!isOpened || page == null) {
       return <></>;
     }
@@ -268,11 +268,10 @@ const PageDuplicateModal = (): JSX.Element => {
         { t('modal_duplicate.label.Duplicate page') }
       </ModalHeader>
       <ModalBody>
-        {bodyContent()}
+        {renderBodyContent()}
       </ModalBody>
       <ModalFooter>
-        <ApiErrorMessageList errs={errs} targetPath={pageNameInput} />
-        {footerContent()}
+        {renderFooterContent()}
       </ModalFooter>
     </Modal>
   );

+ 46 - 66
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);
@@ -133,7 +123,6 @@ const PageEditor = React.memo((): JSX.Element => {
     setMarkdownWithDebounce(value, isClean);
   }, [setMarkdownWithDebounce]);
 
-  // return true if the save succeeds, otherwise false.
   const save = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}): Promise<IPageHasId | null> => {
     if (grantData == null || isSlackEnabled == null || currentPathname == null) {
       logger.error('Some materials to save are invalid', { grantData, isSlackEnabled, currentPathname });
@@ -143,16 +132,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 +166,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 +177,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,19 +186,20 @@ 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) {
       return;
     }
 
-    const isSuccess = await save();
-    if (isSuccess) {
+    const page = await save();
+    if (page != null) {
       toastSuccess(t('toaster.save_succeeded'));
+      await mutateCurrentPageId(page._id);
+      await mutateCurrentPage();
     }
-
-  }, [editorMode, save, t]);
+  }, [editorMode, mutateCurrentPage, mutateCurrentPageId, save, t]);
 
 
   /**
@@ -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}

+ 30 - 19
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -158,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);
 
   }
 
@@ -868,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);
@@ -877,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 [
@@ -1010,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>,
@@ -1025,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>,
@@ -1154,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 <></>;

+ 6 - 2
packages/app/src/components/PageList/PageListItemL.tsx

@@ -162,6 +162,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const canRenderESSnippet = elasticSearchResult != null && elasticSearchResult.snippet != null;
   const canRenderRevisionSnippet = revisionShortBody != null;
 
+  const hasBrowsingRights = canRenderESSnippet || canRenderRevisionSnippet;
+
   return (
     <li
       key={pageData._id}
@@ -228,7 +230,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
               </div>
 
               {/* doropdown icon includes page control buttons */}
-              <div className="ml-auto">
+              {hasBrowsingRights
+              && <div className="ml-auto">
                 <PageItemControl
                   alignRight
                   pageId={pageData._id}
@@ -242,6 +245,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                   onClickRevertMenuItem={revertMenuItemClickHandler}
                 />
               </div>
+              }
             </div>
             <div className="page-list-snippet py-1">
               <Clamp lines={2}>
@@ -253,7 +257,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                   <div data-testid="revision-short-body-in-page-list-item-L">{revisionShortBody}</div>
                 ) }
                 {
-                  !canRenderESSnippet && !canRenderRevisionSnippet && (
+                  !hasBrowsingRights && (
                     <>
                       <i className="icon-exclamation p-1"></i>
                       {t('not_allowed_to_see_this_page')}

+ 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>
   );
 };

+ 18 - 0
packages/app/src/components/ReactMarkdownComponents/Table.tsx

@@ -0,0 +1,18 @@
+import React from 'react';
+
+type TableProps = {
+  children: React.ReactNode,
+  className?: string
+}
+
+export const Table = React.memo((props: TableProps): JSX.Element => {
+
+  const { children, className } = props;
+
+  return (
+    <table className={`${className}`}>
+      {children}
+    </table>
+  );
+});
+Table.displayName = 'Table';

+ 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}
+    />
+  );
+};

+ 6 - 3
packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -1,6 +1,7 @@
 import React, { FC } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 
 import { IRevision } from '~/interfaces/revision';
 import { useSWRxPageByPath } from '~/stores/page';
@@ -19,9 +20,11 @@ const logger = loggerFactory('growi:cli:CustomSidebar');
 const SidebarNotFound = () => {
   return (
     <div className="grw-sidebar-content-header h5 text-center p-3">
-      <a href="/Sidebar#edit">
-        <i className="icon-magic-wand"></i> Create <strong>/Sidebar</strong> page
-      </a>
+      <Link href="/Sidebar#edit">
+        <a href="/Sidebar#edit">
+          <i className="icon-magic-wand"></i> Create <strong>/Sidebar</strong> page
+        </a>
+      </Link>
     </div>
   );
 };

+ 1 - 1
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -112,7 +112,7 @@ const SmallPageItem = memo(({ page }: PageItemProps): JSX.Element => {
   }
 
   return (
-    <li className="list-group-item py-2 px-0">
+    <li className={`list-group-item ${styles['list-group-item']} py-2 px-0`}>
       <div className="d-flex w-100">
         <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
         <div className="flex-grow-1 ml-2">

+ 1 - 0
packages/app/src/components/TableOfContents.tsx

@@ -60,6 +60,7 @@ const TableOfContents = (): JSX.Element => {
       >
         <div
           id="revision-toc-content"
+          data-testid="revision-toc-content"
           className="revision-toc-content mb-3"
         >
           {/* parse blank to show toc (https://github.com/weseek/growi/pull/6277) */}

+ 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;
-};

+ 8 - 0
packages/app/src/interfaces/errors/external-account-login-error.ts

@@ -0,0 +1,8 @@
+import { ExternalAccountLoginError } from '~/models/vo/external-account-login-error';
+
+export type IExternalAccountLoginError = ExternalAccountLoginError;
+
+// type guard
+export const isExternalAccountLoginError = (args: any): args is IExternalAccountLoginError => {
+  return (args as IExternalAccountLoginError).message != null;
+};

+ 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];

+ 11 - 0
packages/app/src/models/vo/external-account-login-error.ts

@@ -0,0 +1,11 @@
+export class ExternalAccountLoginError extends Error {
+
+  args?: any;
+
+  constructor(message = '', args = undefined) {
+    super();
+    this.message = message;
+    this.args = args;
+  }
+
+}

+ 19 - 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,12 +74,19 @@ 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 });
 const GrowiSubNavigationSwitcher = dynamic(() => import('../components/Navbar/GrowiSubNavigationSwitcher'), { ssr: false });
 const UsersHomePageFooter = dynamic<UsersHomePageFooterProps>(() => import('../components/UsersHomePageFooter')
   .then(mod => mod.UsersHomePageFooter), { ssr: false });
+const DrawioModal = dynamic(() => import('../components/PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
 const HandsontableModal = dynamic(() => import('../components/PageEditor/HandsontableModal').then(mod => mod.HandsontableModal), { ssr: false });
 
 const logger = loggerFactory('growi:pages:all');
@@ -154,7 +161,7 @@ type Props = CommonProps & {
   // isMailerSetup: boolean,
   isAclEnabled: boolean,
   // hasSlackConfig: boolean,
-  drawioUri: string,
+  drawioUri: string | null,
   hackmdUri: string,
   noCdn: string,
   // highlightJsStyle: string,
@@ -184,8 +191,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 +306,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">
@@ -351,6 +362,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
 
           <UnsavedAlertDialog />
           <DescendantsPageListModal />
+          <DrawioModal />
           <HandsontableModal />
           {shouldRenderPutbackPageModal && <PutbackPageModal />}
         </div>

+ 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'),

+ 10 - 0
packages/app/src/pages/login.page.tsx

@@ -8,6 +8,7 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import { NoLoginLayout } from '~/components/Layout/NoLoginLayout';
 import { LoginForm } from '~/components/LoginForm';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import { IExternalAccountLoginError, isExternalAccountLoginError } from '~/interfaces/errors/external-account-login-error';
 import type { RegistrationMode } from '~/interfaces/registration-mode';
 
 import {
@@ -30,6 +31,7 @@ type Props = CommonProps & {
   isLdapSetupFailed: boolean,
   isPasswordResetEnabled: boolean,
   isEmailAuthenticationEnabled: boolean,
+  externalAccountLoginError?: IExternalAccountLoginError,
 };
 
 const LoginPage: NextPage<Props> = (props: Props) => {
@@ -54,6 +56,7 @@ const LoginPage: NextPage<Props> = (props: Props) => {
         isPasswordResetEnabled={props.isPasswordResetEnabled}
         isMailerSetup={props.isMailerSetup}
         registrationMode={props.registrationMode}
+        externalAccountLoginError={props.externalAccountLoginError}
       />
     </NoLoginLayout>
   );
@@ -120,6 +123,13 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
 
   const props: Props = result.props as Props;
 
+  if (context.query.externalAccountLoginError != null) {
+    const externalAccountLoginError = context.query.externalAccountLoginError;
+    if (isExternalAccountLoginError(externalAccountLoginError)) {
+      props.externalAccountLoginError = { ...externalAccountLoginError as IExternalAccountLoginError };
+    }
+  }
+
   injectServerConfigurations(context, props);
   injectEnabledStrategies(context, props);
   await injectNextI18NextConfigurations(context, props, ['translation']);

+ 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'),
   };
 }
 

+ 15 - 1
packages/app/src/pages/tags.page.tsx

@@ -3,6 +3,7 @@ import React, { useState, useCallback } from 'react';
 import type { IUser, IUserHasId } from '@growi/core';
 import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { useTranslation } from 'next-i18next';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 import Head from 'next/head';
 
@@ -24,7 +25,7 @@ import {
 } from '../stores/context';
 
 import {
-  CommonProps, getServerSideCommonProps, useCustomTitle,
+  CommonProps, getServerSideCommonProps, getNextI18NextConfig, useCustomTitle,
 } from './utils/commons';
 
 const PAGING_LIMIT = 10;
@@ -139,6 +140,17 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   };
 }
 
+/**
+ * for Server Side Translations
+ * @param context
+ * @param props
+ * @param namespacesRequired
+ */
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  props._nextI18Next = nextI18NextConfig._nextI18Next;
+}
+
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
   const req = context.req as CrowiRequest<IUserHasId & any>;
   const { user } = req;
@@ -152,8 +164,10 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   if (user != null) {
     props.currentUser = user.toObject();
   }
+
   await injectUserUISettings(context, props);
   injectServerConfigurations(context, props);
+  await injectNextI18NextConfigurations(context, props, ['translation']);
 
   return {
     props,

+ 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,

+ 11 - 13
packages/app/src/server/routes/index.js

@@ -82,8 +82,6 @@ module.exports = function(crowi, app) {
   app.get('/invited'                  , applicationInstalled, next.delegateToNext);
   // app.post('/login'                   , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrfProtection,  addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
 
-  app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
-
   // NOTE: get method "/admin/export/:fileName" should be loaded before "/admin/*"
   app.get('/admin/export/:fileName'   , loginRequiredStrictly , adminRequired ,admin.export.api.validators.export.download(), admin.export.download);
 
@@ -97,17 +95,17 @@ module.exports = function(crowi, app) {
   }
 
   // OAuth
-  app.get('/passport/google'                      , loginPassport.loginWithGoogle, loginPassport.loginFailure);
-  app.get('/passport/github'                      , loginPassport.loginWithGitHub, loginPassport.loginFailure);
-  app.get('/passport/twitter'                     , loginPassport.loginWithTwitter, loginPassport.loginFailure);
-  app.get('/passport/oidc'                        , loginPassport.loginWithOidc, loginPassport.loginFailure);
-  app.get('/passport/saml'                        , loginPassport.loginWithSaml, loginPassport.loginFailure);
-  app.get('/passport/basic'                       , loginPassport.loginWithBasic, loginPassport.loginFailure);
-  app.get('/passport/google/callback'             , loginPassport.loginPassportGoogleCallback   , loginPassport.loginFailure);
-  app.get('/passport/github/callback'             , loginPassport.loginPassportGitHubCallback   , loginPassport.loginFailure);
-  app.get('/passport/twitter/callback'            , loginPassport.loginPassportTwitterCallback  , loginPassport.loginFailure);
-  app.get('/passport/oidc/callback'               , loginPassport.loginPassportOidcCallback     , loginPassport.loginFailure);
-  app.post('/passport/saml/callback'              , addActivity, loginPassport.loginPassportSamlCallback, loginPassport.loginFailure);
+  app.get('/passport/google'                      , loginPassport.loginWithGoogle, loginPassport.loginFailureForExternalAccount);
+  app.get('/passport/github'                      , loginPassport.loginWithGitHub, loginPassport.loginFailureForExternalAccount);
+  app.get('/passport/twitter'                     , loginPassport.loginWithTwitter, loginPassport.loginFailureForExternalAccount);
+  app.get('/passport/oidc'                        , loginPassport.loginWithOidc, loginPassport.loginFailureForExternalAccount);
+  app.get('/passport/saml'                        , loginPassport.loginWithSaml, loginPassport.loginFailureForExternalAccount);
+  app.get('/passport/basic'                       , loginPassport.loginWithBasic, loginPassport.loginFailureForExternalAccount);
+  app.get('/passport/google/callback'             , loginPassport.loginPassportGoogleCallback   , loginPassport.loginFailureForExternalAccount);
+  app.get('/passport/github/callback'             , loginPassport.loginPassportGitHubCallback   , loginPassport.loginFailureForExternalAccount);
+  app.get('/passport/twitter/callback'            , loginPassport.loginPassportTwitterCallback  , loginPassport.loginFailureForExternalAccount);
+  app.get('/passport/oidc/callback'               , loginPassport.loginPassportOidcCallback     , loginPassport.loginFailureForExternalAccount);
+  app.post('/passport/saml/callback'              , addActivity, loginPassport.loginPassportSamlCallback, loginPassport.loginFailureForExternalAccount);
 
   app.post('/_api/login/testLdap'    , loginRequiredStrictly , loginFormValidator.loginRules() , loginFormValidator.loginValidation , loginPassport.testLdapCredentials);
 

+ 48 - 45
packages/app/src/server/routes/login-passport.js

@@ -1,8 +1,10 @@
 
 import { ErrorV3 } from '@growi/core';
+import next from 'next';
 
 import { SupportedAction } from '~/interfaces/activity';
 import { LoginErrorCode } from '~/interfaces/errors/login-error';
+import { ExternalAccountLoginError } from '~/models/vo/external-account-login-error';
 import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
 import loggerFactory from '~/utils/logger';
 
@@ -131,20 +133,6 @@ module.exports = function(crowi, app) {
     return res.apiv3({ redirectTo });
   };
 
-  /**
-   * failure handler
-   * @param {*} req
-   * @param {*} res
-   */
-  const loginFailureHandler = async(req, res, message) => {
-    req.flash('errorMessage', message || req.t('message.sign_in_failure'));
-
-    const parameters = { action: SupportedAction.ACTION_USER_LOGIN_FAILURE };
-    activityEvent.emit('update', res.locals.activity._id, parameters);
-
-    return res.redirect('/login');
-  };
-
   const cannotLoginErrorHadnler = (req, res, next) => {
     // this is called when all login method is somehow failed without invoking 'return next(<any Error>)'
     const err = new ErrorV3('message.sign_in_failure');
@@ -166,6 +154,20 @@ module.exports = function(crowi, app) {
     return res.apiv3Err(error);
   };
 
+  const loginFailureForExternalAccount = async(error, req, res, next) => {
+    const parameters = {
+      ip:  req.ip,
+      endpoint: req.originalUrl,
+      action: SupportedAction.ACTION_USER_LOGIN_FAILURE,
+    };
+    await crowi.activityService.createActivity(parameters);
+
+    const { nextApp } = crowi;
+    req.crowi = crowi;
+    nextApp.render(req, res, '/login', { externalAccountLoginError: error });
+    return;
+  };
+
   /**
    * return true(valid) or false(invalid)
    *
@@ -359,8 +361,8 @@ module.exports = function(crowi, app) {
   const loginWithGoogle = function(req, res, next) {
     if (!passportService.isGoogleStrategySetup) {
       debug('GoogleStrategy has not been set up');
-      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'GoogleStrategy' }));
-      return next();
+      const error = new ExternalAccountLoginError('message.strategy_has_not_been_set_up', { strategy: 'GoogleStrategy' });
+      return next(error);
     }
 
     passport.authenticate('google', {
@@ -379,7 +381,7 @@ module.exports = function(crowi, app) {
       response = await promisifiedPassportAuthentication(strategyName, req, res);
     }
     catch (err) {
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError(err.message));
     }
 
     let name;
@@ -413,14 +415,14 @@ module.exports = function(crowi, app) {
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
 
     const user = await externalAccount.getPopulatedUser();
 
     // login
     req.logIn(user, async(err) => {
-      if (err) { debug(err.message); return next() }
+      if (err) { debug(err.message); return next(new ExternalAccountLoginError(err.message)) }
 
       return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_GOOGLE, true);
     });
@@ -429,8 +431,8 @@ module.exports = function(crowi, app) {
   const loginWithGitHub = function(req, res, next) {
     if (!passportService.isGitHubStrategySetup) {
       debug('GitHubStrategy has not been set up');
-      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'GitHubStrategy' }));
-      return next();
+      const error = new ExternalAccountLoginError('message.strategy_has_not_been_set_up', { strategy: 'GitHubStrategy' });
+      return next(error);
     }
 
     passport.authenticate('github')(req, res);
@@ -445,7 +447,7 @@ module.exports = function(crowi, app) {
       response = await promisifiedPassportAuthentication(strategyName, req, res);
     }
     catch (err) {
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError(err.message));
     }
 
     const userInfo = {
@@ -456,14 +458,14 @@ module.exports = function(crowi, app) {
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
 
     const user = await externalAccount.getPopulatedUser();
 
     // login
     req.logIn(user, async(err) => {
-      if (err) { debug(err.message); return next() }
+      if (err) { debug(err.message); return next(new ExternalAccountLoginError(err.message)) }
 
       return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_GITHUB, true);
     });
@@ -472,8 +474,8 @@ module.exports = function(crowi, app) {
   const loginWithTwitter = function(req, res, next) {
     if (!passportService.isTwitterStrategySetup) {
       debug('TwitterStrategy has not been set up');
-      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'TwitterStrategy' }));
-      return next();
+      const error = new ExternalAccountLoginError('message.strategy_has_not_been_set_up', { strategy: 'TwitterStrategy' });
+      return next(error);
     }
 
     passport.authenticate('twitter')(req, res);
@@ -488,7 +490,7 @@ module.exports = function(crowi, app) {
       response = await promisifiedPassportAuthentication(strategyName, req, res);
     }
     catch (err) {
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError(err.message));
     }
 
     const userInfo = {
@@ -499,14 +501,14 @@ module.exports = function(crowi, app) {
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
 
     const user = await externalAccount.getPopulatedUser();
 
     // login
     req.logIn(user, async(err) => {
-      if (err) { debug(err.message); return next() }
+      if (err) { debug(err.message); return next(new ExternalAccountLoginError(err.message)) }
 
       return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_TWITTER, true);
     });
@@ -515,8 +517,8 @@ module.exports = function(crowi, app) {
   const loginWithOidc = function(req, res, next) {
     if (!passportService.isOidcStrategySetup) {
       debug('OidcStrategy has not been set up');
-      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'OidcStrategy' }));
-      return next();
+      const error = new ExternalAccountLoginError('message.strategy_has_not_been_set_up', { strategy: 'OidcStrategy' });
+      return next(error);
     }
 
     passport.authenticate('oidc')(req, res);
@@ -536,7 +538,7 @@ module.exports = function(crowi, app) {
     }
     catch (err) {
       debug(err);
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError(err.message));
     }
 
     const userInfo = {
@@ -549,13 +551,13 @@ module.exports = function(crowi, app) {
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
-      return loginFailureHandler(req, res);
+      return new ExternalAccountLoginError('message.sign_in_failure');
     }
 
     // login
     const user = await externalAccount.getPopulatedUser();
     req.logIn(user, async(err) => {
-      if (err) { debug(err.message); return next() }
+      if (err) { debug(err.message); return next(new ExternalAccountLoginError(err.message)) }
 
       return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_OIDC, true);
     });
@@ -564,8 +566,8 @@ module.exports = function(crowi, app) {
   const loginWithSaml = function(req, res, next) {
     if (!passportService.isSamlStrategySetup) {
       debug('SamlStrategy has not been set up');
-      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'SamlStrategy' }));
-      return next();
+      const error = new ExternalAccountLoginError('message.strategy_has_not_been_set_up', { strategy: 'SamlStrategy' });
+      return next(error);
     }
 
     passport.authenticate('saml')(req, res);
@@ -585,7 +587,7 @@ module.exports = function(crowi, app) {
       response = await promisifiedPassportAuthentication(strategyName, req, res);
     }
     catch (err) {
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError(err.message));
     }
 
     const userInfo = {
@@ -603,12 +605,12 @@ module.exports = function(crowi, app) {
 
     // Attribute-based Login Control
     if (!crowi.passportService.verifySAMLResponseByABLCRule(response)) {
-      return loginFailureHandler(req, res, 'Sign in failure due to insufficient privileges.');
+      return next(new ExternalAccountLoginError('Sign in failure due to insufficient privileges.'));
     }
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
 
     const user = await externalAccount.getPopulatedUser();
@@ -617,7 +619,7 @@ module.exports = function(crowi, app) {
     req.logIn(user, (err) => {
       if (err != null) {
         logger.error(err);
-        return loginFailureHandler(req, res);
+        return next(new ExternalAccountLoginError(err.message));
       }
 
       return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_SAML, true);
@@ -633,8 +635,8 @@ module.exports = function(crowi, app) {
   const loginWithBasic = async(req, res, next) => {
     if (!passportService.isBasicStrategySetup) {
       debug('BasicStrategy has not been set up');
-      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'Basic' }));
-      return next();
+      const error = new ExternalAccountLoginError('message.strategy_has_not_been_set_up', { strategy: 'Basic' });
+      return next(error);
     }
 
     const providerId = 'basic';
@@ -645,7 +647,7 @@ module.exports = function(crowi, app) {
       userId = await promisifiedPassportAuthentication(strategyName, req, res);
     }
     catch (err) {
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError(err.message));
     }
 
     const userInfo = {
@@ -656,12 +658,12 @@ module.exports = function(crowi, app) {
 
     const externalAccount = await getOrCreateUser(req, res, userInfo, providerId);
     if (!externalAccount) {
-      return loginFailureHandler(req, res);
+      return next(new ExternalAccountLoginError('message.sign_in_failure'));
     }
 
     const user = await externalAccount.getPopulatedUser();
     await req.logIn(user, (err) => {
-      if (err) { debug(err.message); return next() }
+      if (err) { debug(err.message); return next(new ExternalAccountLoginError(err.message)) }
 
       return loginSuccessHandler(req, res, user, SupportedAction.ACTION_USER_LOGIN_WITH_BASIC, true);
     });
@@ -670,6 +672,7 @@ module.exports = function(crowi, app) {
   return {
     cannotLoginErrorHadnler,
     loginFailure,
+    loginFailureForExternalAccount,
     loginWithLdap,
     testLdapCredentials,
     loginWithLocal,

+ 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;
-  }
-
-}

+ 20 - 0
packages/app/src/services/renderer/remark-plugins/table.ts

@@ -0,0 +1,20 @@
+import { Plugin } from 'unified';
+import { visit } from 'unist-util-visit';
+
+export const remarkPlugin: Plugin = function() {
+  return (tree) => {
+    visit(tree, (node) => {
+      if (node.type === 'table' || node.type === 'tableCell' || node.type === 'tableRow') {
+
+        // omit position to fix the key regardless of its position
+        // see:
+        //   https://github.com/remarkjs/react-markdown/issues/703
+        //   https://github.com/remarkjs/react-markdown/issues/466
+        //
+        //   https://github.com/remarkjs/react-markdown/blob/a80dfdee2703d84ac2120d28b0e4998a5b417c85/lib/ast-to-react.js#L201-L204
+        //   https://github.com/remarkjs/react-markdown/blob/a80dfdee2703d84ac2120d28b0e4998a5b417c85/lib/ast-to-react.js#L217-L222
+        delete node.position;
+      }
+    });
+  };
+};

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

@@ -1,9 +1,10 @@
 // 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 * as drawioPlugin from '@growi/remark-drawio-plugin';
 import growiPlugin from '@growi/remark-growi-plugin';
+import { Lsx, LsxImmutable } from '@growi/remark-lsx/components';
+import * as lsxGrowiPlugin from '@growi/remark-lsx/services/renderer';
 import { Schema as SanitizeOption } from 'hast-util-sanitize';
 import { SpecialComponents } from 'react-markdown/lib/ast-to-react';
 import { NormalComponents } from 'react-markdown/lib/complex-types';
@@ -22,8 +23,11 @@ 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 { Table } from '~/components/ReactMarkdownComponents/Table';
+import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
 import { RendererConfig } from '~/interfaces/services/renderer';
 import loggerFactory from '~/utils/logger';
 
@@ -35,6 +39,7 @@ import { relativeLinksByPukiwikiLikeLinker } from './rehype-plugins/relative-lin
 import * as toc from './rehype-plugins/relocate-toc';
 import * as plantuml from './remark-plugins/plantuml';
 import { pukiwikiLikeLinker } from './remark-plugins/pukiwiki-like-linker';
+import * as table from './remark-plugins/table';
 import * as xsvToTable from './remark-plugins/xsv-to-table';
 
 // import CsvToTable from './PreProcessor/CsvToTable';
@@ -320,6 +325,7 @@ export const generateViewOptions = (
   remarkPlugins.push(
     math,
     [plantuml.remarkPlugin, { baseUrl: config.plantumlUri }],
+    drawioPlugin.remarkPlugin,
     xsvToTable.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
   );
@@ -333,6 +339,7 @@ export const generateViewOptions = (
     [lsxGrowiPlugin.rehypePlugin, { pagePath }],
     [sanitize, deepmerge(
       commonSanitizeOption,
+      drawioPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
     )],
     katex,
@@ -347,7 +354,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,8 +406,10 @@ export const generateSimpleViewOptions = (config: RendererConfig, pagePath: stri
   remarkPlugins.push(
     math,
     [plantuml.remarkPlugin, { baseUrl: config.plantumlUri }],
+    drawioPlugin.remarkPlugin,
     xsvToTable.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
+    table.remarkPlugin,
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -410,6 +421,7 @@ export const generateSimpleViewOptions = (config: RendererConfig, pagePath: stri
     [keywordHighlighter.rehypePlugin, { keywords: highlightKeywords }],
     [sanitize, deepmerge(
       commonSanitizeOption,
+      drawioPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
     )],
     katex,
@@ -417,7 +429,9 @@ export const generateSimpleViewOptions = (config: RendererConfig, pagePath: stri
 
   // add components
   if (components != null) {
-    components.lsx = props => <Lsx {...props} />;
+    components.lsx = LsxImmutable;
+    components.drawio = drawioPlugin.DrawioViewer;
+    components.table = Table;
   }
 
   verifySanitizePlugin(options, false);
@@ -433,8 +447,10 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
   remarkPlugins.push(
     math,
     [plantuml.remarkPlugin, { baseUrl: config.plantumlUri }],
+    drawioPlugin.remarkPlugin,
     xsvToTable.remarkPlugin,
     lsxGrowiPlugin.remarkPlugin,
+    table.remarkPlugin,
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -447,6 +463,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     [sanitize, deepmerge(
       commonSanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
+      drawioPlugin.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
     )],
     katex,
@@ -454,10 +471,12 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
 
   // add components
   if (components != null) {
-    components.lsx = props => <Lsx {...props} />;
+    components.lsx = LsxImmutable;
+    components.drawio = drawioPlugin.DrawioViewer;
+    components.table = Table;
   }
 
-  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;
 

+ 1 - 1
packages/app/src/styles/organisms/_wiki-custom-sidebar.scss

@@ -39,7 +39,7 @@
     margin: 10px 0;
   }
 
-  .page-list .page-list-ul {
+  .lsx.page-list > .page-list-ul {
     padding-left: 0;
     margin: 0;
   }

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio