Kaynağa Gözat

Merge branch 'support/apply-nextjs-2' into imprv/101812-show-external-account-manegement-page

kaori 3 yıl önce
ebeveyn
işleme
c5cbdc5026
100 değiştirilmiş dosya ile 1370 ekleme ve 983 silme
  1. 5 2
      .github/workflows/reusable-app-prod.yml
  2. 4 1
      .vscode/settings.json
  3. 21 1
      CHANGELOG.md
  4. 1 1
      lerna.json
  5. 2 2
      package.json
  6. 1 0
      packages/app/_obsolete/config/webpack.common.js
  7. 0 0
      packages/app/_obsolete/src/client/boot.js
  8. 0 0
      packages/app/_obsolete/src/util/i18n.js
  9. 0 0
      packages/app/_obsolete/src/util/locale-utils.js
  10. 0 0
      packages/app/_obsolete/src/util/old-ios.js
  11. 1 0
      packages/app/config/ci/.env.local.for-ci
  12. 2 2
      packages/app/docker/README.md
  13. 5 22
      packages/app/next.config.js
  14. 19 14
      packages/app/package.json
  15. 7 5
      packages/app/public/static/locales/en_US/admin.json
  16. 2 0
      packages/app/public/static/locales/en_US/translation.json
  17. 6 4
      packages/app/public/static/locales/ja_JP/admin.json
  18. 2 0
      packages/app/public/static/locales/ja_JP/translation.json
  19. 7 4
      packages/app/public/static/locales/zh_CN/admin.json
  20. 2 0
      packages/app/public/static/locales/zh_CN/translation.json
  21. 1 0
      packages/app/regconfig.json
  22. 23 0
      packages/app/src/client/services/page-operation.ts
  23. 18 2
      packages/app/src/components/Admin/AuditLog/AuditLogSettings.tsx
  24. 28 20
      packages/app/src/components/Admin/AuditLog/DateRangePicker.tsx
  25. 8 11
      packages/app/src/components/Admin/AuditLogManagement.tsx
  26. 20 10
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  27. 3 0
      packages/app/src/components/CommonStyles/katex.module.scss
  28. 3 3
      packages/app/src/components/DescendantsPageList.tsx
  29. 17 3
      packages/app/src/components/DescendantsPageListModal.tsx
  30. 5 0
      packages/app/src/components/IdenticalPathPage.module.scss
  31. 4 1
      packages/app/src/components/IdenticalPathPage.tsx
  32. 23 27
      packages/app/src/components/InstallerForm.jsx
  33. 3 0
      packages/app/src/components/Layout/Admin.module.scss
  34. 3 1
      packages/app/src/components/Me/BasicInfoSettings.tsx
  35. 31 0
      packages/app/src/components/Navbar/AuthorInfo.module.scss
  36. 3 1
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  37. 10 32
      packages/app/src/components/Navbar/GrowiSubNavigation.module.scss
  38. 13 5
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  39. 36 31
      packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx
  40. 10 2
      packages/app/src/components/Navbar/PageEditorModeManager.module.scss
  41. 71 6
      packages/app/src/components/Navbar/SubNavButtons.tsx
  42. 8 6
      packages/app/src/components/Page.jsx
  43. 5 0
      packages/app/src/components/Page/DisplaySwitcher.module.scss
  44. 6 8
      packages/app/src/components/Page/DisplaySwitcher.tsx
  45. 1 1
      packages/app/src/components/Page/RenderTagLabels.tsx
  46. 76 1
      packages/app/src/components/Page/RevisionRenderer.tsx
  47. 18 0
      packages/app/src/components/Page/TagLabels.module.scss
  48. 4 2
      packages/app/src/components/Page/TagLabels.tsx
  49. 0 6
      packages/app/src/components/PageAccessoriesModal.module.scss
  50. 2 1
      packages/app/src/components/PageAccessoriesModal.tsx
  51. 0 108
      packages/app/src/components/PageAccessoriesModalControl.jsx
  52. 2 4
      packages/app/src/components/PageAlert/PageGrantAlert.tsx
  53. 1 1
      packages/app/src/components/PageEditor/DrawioModal.jsx
  54. 0 406
      packages/app/src/components/PageEditor/Editor.jsx
  55. 363 0
      packages/app/src/components/PageEditor/Editor.tsx
  56. 1 1
      packages/app/src/components/PageList/PageListItemL.tsx
  57. 1 1
      packages/app/src/components/PagePresentationModal.jsx
  58. 0 4
      packages/app/src/components/PagePresentationModal.module.scss
  59. 14 0
      packages/app/src/components/PageTimeline.module.scss
  60. 4 2
      packages/app/src/components/PageTimeline.tsx
  61. 1 12
      packages/app/src/components/PrivateLegacyPages.tsx
  62. 22 0
      packages/app/src/components/ReactMarkdownComponents/CodeBlock.module.scss
  63. 33 0
      packages/app/src/components/ReactMarkdownComponents/CodeBlock.tsx
  64. 5 23
      packages/app/src/components/SearchPage.tsx
  65. 10 8
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  66. 0 1
      packages/app/src/components/SearchPage/SearchResultList.tsx
  67. 2 6
      packages/app/src/components/SearchPage2/SearchPageBase.tsx
  68. 2 2
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  69. 2 0
      packages/app/src/components/Sidebar/RecentChanges.tsx
  70. 2 9
      packages/app/src/components/Skelton.tsx
  71. 11 0
      packages/app/src/components/TableOfContents.module.scss
  72. 19 13
      packages/app/src/components/TableOfContents.tsx
  73. 17 0
      packages/app/src/interfaces/editor-methods.ts
  74. 15 4
      packages/app/src/interfaces/page-operation.ts
  75. 4 0
      packages/app/src/interfaces/services/renderer.ts
  76. 5 0
      packages/app/src/interfaces/theme.ts
  77. 9 12
      packages/app/src/pages/[[...path]].page.tsx
  78. 5 21
      packages/app/src/pages/_document.page.tsx
  79. 15 15
      packages/app/src/pages/_search.page.tsx
  80. 4 1
      packages/app/src/server/models/obsolete-page.js
  81. 1 16
      packages/app/src/server/models/page-operation.ts
  82. 1 0
      packages/app/src/server/models/page.ts
  83. 27 2
      packages/app/src/server/routes/apiv3/page.js
  84. 7 1
      packages/app/src/server/routes/apiv3/pages.js
  85. 15 5
      packages/app/src/server/service/page-operation.ts
  86. 95 10
      packages/app/src/server/service/page.ts
  87. 3 0
      packages/app/src/server/util/rate-limiter.ts
  88. 9 1
      packages/app/src/server/views/layout/layout.html
  89. 3 1
      packages/app/src/services/renderer/markdown-it/link-by-relative-path.ts
  90. 39 0
      packages/app/src/services/renderer/rehype-plugins/add-class.ts
  91. 10 0
      packages/app/src/services/renderer/renderer.tsx
  92. 8 1
      packages/app/src/stores/page.tsx
  93. 0 3
      packages/app/src/styles/_drawio.scss
  94. 0 8
      packages/app/src/styles/_layout.scss
  95. 0 27
      packages/app/src/styles/_mixins.scss
  96. 32 0
      packages/app/src/styles/_modal.scss
  97. 6 0
      packages/app/src/styles/_page-history.scss
  98. 2 7
      packages/app/src/styles/_page_list.scss
  99. 1 1
      packages/app/src/styles/_search.scss
  100. 2 8
      packages/app/src/styles/_tag.scss

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

@@ -75,7 +75,7 @@ jobs:
     - name: Upload production files as artifact
       uses: actions/upload-artifact@v3
       with:
-        name: Production Files
+        name: Production Files (node${{ inputs.node-version }})
         path: ${{ steps.archive-prod-files.outputs.file }}
 
     - name: Upload report as artifact
@@ -155,7 +155,7 @@ jobs:
     - name: Download production files artifact
       uses: actions/download-artifact@v3
       with:
-        name: Production Files
+        name: Production Files (node${{ inputs.node-version }})
 
     - name: Extract procution files artifact
       run: |
@@ -210,6 +210,9 @@ jobs:
     steps:
     - uses: actions/checkout@v3
 
+    - name: Install fonts
+      run: sudo apt install fonts-noto
+
     - uses: actions/setup-node@v3
       with:
         node-version: ${{ matrix.node-version }}

+ 4 - 1
.vscode/settings.json

@@ -21,5 +21,8 @@
   "editor.codeActionsOnSave": {
     "source.fixAll.eslint": true,
     "source.fixAll.markdownlint": true
-  }
+  },
+  "githubPullRequests.ignoredPullRequestBranches": [
+    "master"
+  ]
 }

+ 21 - 1
CHANGELOG.md

@@ -1,9 +1,29 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.1.0...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.1.1...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v5.1.1](https://github.com/weseek/growi/compare/v5.1.0...v5.1.1) - 2022-08-01
+
+### 💎 Features
+
+- feat: Users can set users per ip from env var at API Rate Limit  (#6379) @yukendev
+- feat: Show user picture in Audit Log (#6342) @miya
+- feat: Reset search criteria button (#6327) @miya
+
+### 🚀 Improvement
+
+- imprv(auditlog): Display number of actions that can be saved (#6353) @miya
+- imprv(auditlog): Include delete-related actions in small group (#6351) @miya
+
+### 🐛 Bug Fixes
+
+- fix: Default markdown linker with relative path does not respect the current page path (v5.1.0) (#6378) @yuki-takei
+- fix: Recover page path operation (#6368) @hakumizuki
+- fix: Migration script for inserting NamedQuery (#6364) @yuki-takei
+- fix: "Error: cannnot get grant label" occured with lsx (#6348) @yukendev
+
 ## [v5.1.0](https://github.com/weseek/growi/compare/v5.0.11...v5.1.0) - 2022-07-21
 
 ### 💎 Features

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "5.1.1-RC.0",
+  "version": "5.1.2-RC.0",
   "packages": [
     "packages/*"
   ]

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "5.1.1-RC.0",
+  "version": "5.1.2-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -76,7 +76,7 @@
     "reg-notify-github-plugin": "^0.11.1",
     "reg-notify-slack-plugin": "^0.11.0",
     "reg-publish-s3-plugin": "^0.11.0",
-    "reg-suit": "^0.11.1",
+    "reg-suit": "^0.12.1",
     "rewire": "^5.0.0",
     "shipjs": "^0.24.1",
     "stylelint": "^14.2.0",

+ 1 - 0
packages/app/_obsolete/config/webpack.common.js

@@ -77,6 +77,7 @@ module.exports = (options) => {
     },
     node: {
       fs: 'empty',
+      module: 'empty',
     },
     module: {
       rules: options.module.rules.concat([

+ 0 - 0
packages/app/src/client/boot.js → packages/app/_obsolete/src/client/boot.js


+ 0 - 0
packages/app/src/client/util/i18n.js → packages/app/_obsolete/src/util/i18n.js


+ 0 - 0
packages/app/src/client/util/locale-utils.js → packages/app/_obsolete/src/util/locale-utils.js


+ 0 - 0
packages/app/src/client/util/old-ios.js → packages/app/_obsolete/src/util/old-ios.js


+ 1 - 0
packages/app/config/ci/.env.local.for-ci

@@ -1 +1,2 @@
 FORMAT_NODE_LOG=true
+MATHJAX=1

+ 2 - 2
packages/app/docker/README.md

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`5.1.0`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.0/docker/Dockerfile)
-* [`5.1.0-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.0/docker/Dockerfile)
+* [`5.1.1`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.1/docker/Dockerfile)
+* [`5.1.1-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.1/docker/Dockerfile)
 * [`5.0.11`, `5.0` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
 * [`5.0.11-nocdn`, `5.0-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
 * [`4.5.23`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)

+ 5 - 22
packages/app/next.config.js

@@ -9,12 +9,6 @@ const { withSuperjson } = require('next-superjson');
 const { PHASE_PRODUCTION_BUILD, PHASE_PRODUCTION_SERVER } = require('next/constants');
 
 
-// define additional entries
-const additionalWebpackEntries = {
-  boot: './src/client/boot',
-};
-
-
 const setupTranspileModules = () => {
   const eazyLogger = require('eazy-logger');
   const { listScopedPackages, listPrefixedPackages } = require('./src/utils/next.config.utils');
@@ -33,7 +27,9 @@ const setupTranspileModules = () => {
     'unified',
     'comma-separated-tokens',
     'decode-named-character-reference',
+    'hastscript',
     'html-void-elements',
+    'longest-streak',
     'property-information',
     'space-separated-tokens',
     'trim-lines',
@@ -41,7 +37,9 @@ const setupTranspileModules = () => {
     'vfile',
     'zwitch',
     'emoticon',
-    ...listPrefixedPackages(['remark-', 'rehype-', 'hast-', 'mdast-', 'micromark-', 'micromark-', 'unist-']),
+    'direction', // for hast-util-select
+    'bcp-47-match', // for hast-util-select
+    ...listPrefixedPackages(['remark-', 'rehype-', 'hast-', 'mdast-', 'micromark-', 'unist-']),
   ];
 
   logger.info('{bold:Listing scoped packages for transpiling:}');
@@ -83,21 +81,6 @@ module.exports = async(phase, { defaultConfig }) => {
       config.externals.push('dtrace-provider');
       config.externals.push('mongoose');
 
-      // configure additional entries
-      const orgEntry = config.entry;
-      config.entry = () => {
-        return orgEntry().then((entry) => {
-          return { ...entry, ...additionalWebpackEntries };
-        });
-      };
-
-      const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
-      config.plugins.push(
-        new WebpackManifestPlugin({
-          fileName: 'custom-manifest.json',
-        }),
-      );
-
       // setup i18next-hmr
       if (!options.isServer && options.dev) {
         const { I18NextHMRPlugin } = require('i18next-hmr/plugin');

+ 19 - 14
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.1.1-RC.0",
+  "version": "5.1.2-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -45,8 +45,9 @@
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
     "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
-    "resources:plugin": "yarn ts-node bin/generate-plugin-definitions-source.ts",
-    "resources:dl-resources": "yarn ts-node bin/download-cdn-resources.ts",
+    "resources:dummy": "true",
+    "// resources:plugin": "yarn ts-node bin/generate-plugin-definitions-source.ts",
+    "// resources:dl-resources": "yarn ts-node bin/download-cdn-resources.ts",
     "ts-node": "ts-node -r tsconfig-paths/register -r dotenv-flow/config --transpile-only"
   },
   "// comments for dependencies": {
@@ -57,17 +58,17 @@
   "dependencies": {
     "@aws-sdk/client-s3": "^3.58.0",
     "@aws-sdk/s3-request-presigner": "^3.58.0",
-    "@browser-bunyan/console-formatted-stream": "^1.6.2",
+    "@browser-bunyan/console-formatted-stream": "^1.8.0",
     "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.8",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.1.1-RC.0",
-    "@growi/core": "^5.1.1-RC.0",
-    "@growi/plugin-attachment-refs": "^5.1.1-RC.0",
-    "@growi/plugin-lsx": "^5.1.1-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.1.1-RC.0",
-    "@growi/slack": "^5.1.1-RC.0",
+    "@growi/codemirror-textlint": "^5.1.2-RC.0",
+    "@growi/core": "^5.1.2-RC.0",
+    "@growi/plugin-attachment-refs": "^5.1.2-RC.0",
+    "@growi/plugin-lsx": "^5.1.2-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.1.2-RC.0",
+    "@growi/slack": "^5.1.2-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -81,7 +82,7 @@
     "axios": "^0.24.0",
     "axios-retry": "^3.2.4",
     "body-parser": "^1.18.2",
-    "browser-bunyan": "^1.6.3",
+    "browser-bunyan": "^1.8.0",
     "bson-objectid": "^2.0.3",
     "bunyan": "^1.8.15",
     "check-node-version": "^4.1.0",
@@ -108,6 +109,7 @@
     "express-webpack-assets": "^0.1.0",
     "extensible-custom-error": "^0.0.7",
     "graceful-fs": "^4.1.11",
+    "hast-util-select": "^5.0.2",
     "helmet": "^4.6.0",
     "http-errors": "^2.0.0",
     "i18next-chained-backend": "^3.0.2",
@@ -142,6 +144,7 @@
     "passport-local": "^1.0.0",
     "passport-saml": "^3.2.0",
     "passport-twitter": "^1.0.4",
+    "prism-themes": "^1.9.0",
     "prom-client": "^13.0.0",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
@@ -153,8 +156,10 @@
     "react-image-crop": "^8.3.0",
     "react-markdown": "^8.0.3",
     "react-multiline-clamp": "^2.0.0",
+    "react-syntax-highlighter": "^15.5.0",
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
+    "rehype-katex": "^6.0.2",
     "rehype-raw": "^6.1.1",
     "rehype-sanitize": "^5.0.1",
     "rehype-slug": "^5.0.1",
@@ -162,6 +167,7 @@
     "remark-breaks": "^3.0.2",
     "remark-emoji": "^3.0.2",
     "remark-gfm": "^3.0.1",
+    "remark-math": "^5.1.1",
     "rimraf": "^3.0.0",
     "socket.io": "^4.2.0",
     "stream-to-promise": "^3.0.0",
@@ -184,7 +190,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.1.1-RC.0",
+    "@growi/ui": "^5.1.2-RC.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^12.2.3",
@@ -247,7 +253,6 @@
     "ts-node": "^9.1.1",
     "ts-node-dev": "^2.0.0",
     "tsc-alias": "^1.2.9",
-    "unstated": "^2.1.1",
-    "webpack-manifest-plugin": "^5.0.0"
+    "unstated": "^2.1.1"
   }
 }

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

@@ -422,7 +422,7 @@
     }
   },
   "slack_integration_legacy": {
-    "alert_disabled": "This 'Legacy Slack Integration' is currently disabled because <a href='/admin/slack-integration'>the new settings</a> is enabled.",
+    "alert_Pd": "This 'Legacy Slack Integration' is currently disabled because <a href='/admin/slack-integration'>the new settings</a> is enabled.",
     "alert_deplicated": "This 'Legacy Slack Integration' is outdated and will be discontinued in the future. Use <a href='/admin/slack-integration'>the new settings</a> instead. "
   },
   "user_management": {
@@ -537,15 +537,17 @@
     "url": "URL",
     "settings": "Settings",
     "return": "Return",
-    "clear": "Clear search criteria",
-    "reload": "Reload",
+    "clear": "Clear",
     "activity_expiration_date": "Audit Log expiration date",
     "activity_expiration_date_explain": "Created Audit Log are automatically deleted after the number of seconds set in the environment variable from the creation time",
     "fixed_by_env_var": "This is fixed by the env var <code>{{key}}={{value}}</code>.",
     "available_action_list": "Search / View All Available Actions",
-    "available_action_list_explain": "List of actions that can be search / view in the Audit Log",
+    "available_action_list_explain": "List of actions that can be searched/viewed in the current settings",
     "action_list": "Action List",
-    "disable_mode_explain": "Audit log is currently disabled. To enable it, set the environment variable <code>AUDIT_LOG_ENABLED</code> to true."
+    "disable_mode_explain": "Audit log is currently disabled. To enable it, set the environment variable <code>AUDIT_LOG_ENABLED</code> to true.",
+    "docs_url": {
+      "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
+    }
   },
   "audit_log_action_category": {
     "Page": "Page",

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

@@ -168,6 +168,7 @@
   "No bookmarks yet": "No bookmarks yet",
   "add_bookmark": "Add to Bookmarks",
   "remove_bookmark": "Remove from Bookmarks",
+  "wide_view": "Wide View",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
   "Page Tree": "Page Tree",
@@ -545,6 +546,7 @@
     "create_failed": "Failed to create {{target}}",
     "update_successed": "Succeeded to update {{target}}",
     "update_failed": "Failed to update {{target}}",
+    "file_upload_failed": "File upload failed.",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin",

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

@@ -536,15 +536,17 @@
     "url": "URL",
     "settings": "設定",
     "return": "戻る",
-    "clear": "検索条件のクリア",
-    "reload": "再読み込み",
+    "clear": "クリア",
     "activity_expiration_date": "監査ログの有効期限",
     "activity_expiration_date_explain": "作成された監査ログは、作成時間から環境変数に設定した秒数後に自動的に削除されます",
     "fixed_by_env_var": "環境変数により固定されています <code>{{key}}={{value}}</code>.",
     "available_action_list": "検索 / 表示 可能なアクション一覧",
-    "available_action_list_explain": "監査ログで 検索 / 表示 可能なアクション一覧です",
+    "available_action_list_explain": "現在の設定で検索 / 表示 可能なアクション一覧です",
     "action_list": "アクション一覧",
-    "disable_mode_explain": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。"
+    "disable_mode_explain": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。",
+    "docs_url": {
+      "log_type": "https://docs.growi.org/ja/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
+    }
   },
   "audit_log_action_category": {
     "Page": "ページ",

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

@@ -170,6 +170,7 @@
   "No bookmarks yet": "No bookmarks yet",
   "add_bookmark": "ブックマークに追加",
   "remove_bookmark": "ブックマークから削除",
+  "wide_view": "ワイドビュー",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
   "Page Tree": "ページツリー",
@@ -545,6 +546,7 @@
     "create_failed": "{{target}}の作成に失敗しました",
     "update_successed": "{{target}}を更新しました",
     "update_failed": "{{target}}の更新に失敗しました",
+    "file_upload_failed": "ファイルのアップロードに失敗しました",
     "initialize_successed": "{{target}}を初期化しました",
     "give_user_admin": "{{username}}を管理者に設定しました",
     "remove_user_admin": "{{username}}を管理者から外しました",

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

@@ -546,15 +546,18 @@
     "url": "URL",
     "settings": "设置",
     "return": "返回",
-    "clear": "清除搜索标准",
-    "reload": "重新加载",
+    "clear": "清除",
     "activity_expiration_date": "审计日志的到期日",
     "activity_expiration_date_explain": "创建的审计日志会在环境变量中设置的从创建时间算起的秒数后自动删除",
     "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
     "available_action_list": "搜索/查看 所有可用的行动",
-    "available_action_list_explain": "可以在审计日志中 搜索/查看 的行动列表",
+    "available_action_list_explain": "在当前配置中可以搜索/查看的行动列表",
     "action_list": "行动清单",
-    "disable_mode_explain": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。"
+    "disable_mode_explain": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。",
+    "docs_url": {
+      "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
+    }
+
   },
   "audit_log_action_category": {
     "Page": "页面",

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

@@ -176,6 +176,7 @@
   "No bookmarks yet": "暂无书签",
   "add_bookmark": "添加到书签",
   "remove_bookmark": "从书签中删除",
+  "wide_view": "视野开阔",
 	"Recent Created": "最新创建",
   "Recent Changes": "最新修改",
   "Page Tree": "页面树",
@@ -523,6 +524,7 @@
     "create_failed": "Failed to create {{target}}",
 		"update_successed": "Succeeded to update {{target}}",
     "update_failed": "Failed to update {{target}}",
+    "file_upload_failed": "文件上传失败",
     "initialize_successed": "Succeeded to initialize {{target}}",
 		"give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin ",

+ 1 - 0
packages/app/regconfig.json

@@ -13,6 +13,7 @@
     "reg-notify-github-plugin": {
       "prCommentBehavior": "new",
       "setCommitStatus": false,
+      "shortDescription": true,
       "clientId": "$REG_NOTIFY_GITHUB_PLUGIN_CLIENTID"
     },
     "reg-notify-slack-plugin": {

+ 23 - 0
packages/app/src/client/services/page-operation.ts

@@ -36,6 +36,29 @@ export const toggleBookmark = async(pageId: string, currentValue?: boolean): Pro
   }
 };
 
+// Utility to update body class
+const updateBodyClassByView = (expandContentWidth: boolean): void => {
+  const bodyClasses = document.body.classList;
+  const isLayoutFluid = bodyClasses.contains('growi-layout-fluid');
+
+  if (expandContentWidth && !isLayoutFluid) {
+    bodyClasses.add('growi-layout-fluid');
+  }
+  else if (isLayoutFluid) {
+    bodyClasses.remove('growi-layout-fluid');
+  }
+};
+
+export const updateContentWidth = async(pageId: string, newValue: boolean): Promise<void> => {
+  try {
+    await apiv3Put(`/page/${pageId}/content-width`, { expandContentWidth: newValue });
+    updateBodyClassByView(newValue);
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
 export const bookmark = async(pageId: string): Promise<void> => {
   try {
     await apiv3Put('/bookmarks', { pageId, bool: true });

+ 18 - 2
packages/app/src/components/Admin/AuditLog/AuditLogSettings.tsx

@@ -3,6 +3,7 @@ import React, { FC, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Collapse } from 'reactstrap';
 
+import { AllSupportedActions } from '~/interfaces/activity';
 import { useActivityExpirationSeconds, useAuditLogAvailableActions } from '~/stores/context';
 
 export const AuditLogSettings: FC = () => {
@@ -34,8 +35,23 @@ export const AuditLogSettings: FC = () => {
         />
       </p>
 
-      <h4 className="mt-4">{t('admin:audit_log_management.available_action_list')}</h4>
-      <p className="form-text text-muted">{t('admin:audit_log_management.available_action_list_explain')}</p>
+      <h4 className="mt-4">
+        {t('admin:audit_log_management.available_action_list')}
+        <span className="badge badge-pill badge-secondary ml-2">
+          {`${availableActions.length} / ${AllSupportedActions.length}`}
+        </span>
+        <a
+          className="ml-2"
+          href={t('admin:audit_log_management.docs_url.log_type')}
+          target="_blank"
+          rel="noopener noreferrer"
+        >
+          <i className="icon-fw icon-question" aria-hidden="true"></i>
+        </a>
+      </h4>
+      <p className="form-text text-muted">
+        {t('admin:audit_log_management.available_action_list_explain')}
+      </p>
       <p className="mt-1">
         <button type="button" className="btn btn-link p-0" aria-expanded="false" onClick={() => setIsExpandActionList(!isExpandActionList)}>
           <i className={`fa fa-fw fa-arrow-right ${isExpandActionList ? 'fa-rotate-90' : ''}`}></i>

+ 28 - 20
packages/app/src/components/Admin/AuditLog/DateRangePicker.tsx

@@ -1,29 +1,39 @@
-import React, {
-  FC, useRef, forwardRef, useCallback,
-} from 'react';
+import React, { FC, forwardRef, useCallback } from 'react';
 
+import { addDays, format } from 'date-fns';
 import DatePicker from 'react-datepicker';
 import 'react-datepicker/dist/react-datepicker.css';
 
-import { useTranslation } from 'react-i18next';
-
 
 type CustomInputProps = {
-  buttonRef: React.Ref<HTMLButtonElement>
-  onClick?: () => void;
+  value?: string
+  onChange?: () => void
+  onFocus?: () => void
 }
 
-const CustomInput = forwardRef<HTMLButtonElement, CustomInputProps>((props: CustomInputProps) => {
-  const { t } = useTranslation();
+const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>((props: CustomInputProps, ref) => {
+  const dateFormat = 'MM/dd/yyyy';
+  const date = new Date();
+  const placeholder = `${format(date, dateFormat)} - ${format(addDays(date, 1), dateFormat)}`;
+
   return (
-    <button
-      type="button"
-      className="btn btn-outline-secondary dropdown-toggle"
-      ref={props.buttonRef}
-      onClick={props.onClick}
-    >
-      <i className="fa fa-fw fa-calendar" /> {t('admin:audit_log_management.date')}
-    </button>
+    <div className="input-group admin-audit-log">
+      <div className="input-group-prepend">
+        <span className="input-group-text">
+          <i className="fa fa-fw fa-calendar" />
+        </span>
+      </div>
+      <input
+        ref={ref}
+        type="text"
+        value={props?.value}
+        onFocus={props?.onFocus}
+        onChange={props?.onChange}
+        placeholder={placeholder}
+        className="form-control date-range-picker"
+        aria-describedby="basic-addon1"
+      />
+    </div>
   );
 });
 
@@ -38,8 +48,6 @@ type DateRangePickerProps = {
 export const DateRangePicker: FC<DateRangePickerProps> = (props: DateRangePickerProps) => {
   const { startDate, endDate, onChange } = props;
 
-  const buttonRef = useRef(null);
-
   const changeHandler = useCallback((dateList: Date[] | null[]) => {
     if (onChange != null) {
       const [start, end] = dateList;
@@ -60,7 +68,7 @@ export const DateRangePicker: FC<DateRangePickerProps> = (props: DateRangePicker
         startDate={startDate}
         endDate={endDate}
         onChange={changeHandler}
-        customInput={<CustomInput buttonRef={buttonRef} />}
+        customInput={<CustomInput />}
       />
     </div>
   );

+ 8 - 11
packages/app/src/components/Admin/AuditLogManagement.tsx

@@ -135,6 +135,11 @@ export const AuditLogManagement: FC = () => {
         <span>
           {isSettingPage ? t('AuditLog Settings') : t('AuditLog')}
         </span>
+        { !isSettingPage && (
+          <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={reloadButtonPushedHandler}>
+            <i className="icon icon-reload"></i>
+          </button>
+        )}
       </h2>
 
       {isSettingPage ? (
@@ -160,17 +165,9 @@ export const AuditLogManagement: FC = () => {
               onChangeMultipleAction={multipleActionCheckboxChangedHandler}
             />
 
-            <div className="ml-auto">
-              <button type="button" className="btn btn-outline-secondary btn-sm mr-2" onClick={clearButtonPushedHandler}>
-                <span className="icon-refresh mr-1" />
-                {t('admin:audit_log_management.clear')}
-              </button>
-
-              <button type="button" className="btn btn-outline-secondary btn-sm" onClick={reloadButtonPushedHandler}>
-                <i className="icon icon-reload mr-1" />
-                {t('admin:audit_log_management.reload')}
-              </button>
-            </div>
+            <button type="button" className="btn btn-link" onClick={clearButtonPushedHandler}>
+              {t('admin:audit_log_management.clear')}
+            </button>
           </div>
 
           <p

+ 20 - 10
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -11,6 +11,7 @@ import {
 import { IPageOperationProcessData } from '~/interfaces/page-operation';
 import { useSWRxPageInfo } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
+import { shouldRecoverPagePaths } from '~/utils/page-operation';
 
 const logger = loggerFactory('growi:cli:PageItemControl');
 
@@ -22,6 +23,7 @@ export const MenuItemType = {
   DELETE: 'delete',
   REVERT: 'revert',
   PATH_RECOVERY: 'pathRecovery',
+  SWITCH_CONTENT_WIDTH: 'switch_content_width',
 } as const;
 export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
 
@@ -41,6 +43,7 @@ type CommonProps = {
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
   onClickPathRecoveryMenuItem?: (pageId: string) => Promise<void> | void,
 
+  additionalMenuItemOnTopRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   isInstantRename?: boolean,
   alignRight?: boolean,
@@ -57,13 +60,14 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
   const { t } = useTranslation('');
 
   const {
-    pageId, isLoading,
-    pageInfo, isEnableActions, forceHideMenuItems, operationProcessData,
-    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem, onClickPathRecoveryMenuItem,
-    additionalMenuItemRenderer: AdditionalMenuItems, isInstantRename, alignRight,
+    pageId, isLoading, pageInfo, isEnableActions, forceHideMenuItems, operationProcessData,
+    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem,
+    onClickRevertMenuItem, onClickPathRecoveryMenuItem,
+    additionalMenuItemOnTopRenderer: AdditionalMenuItemsOnTop,
+    additionalMenuItemRenderer: AdditionalMenuItems,
+    isInstantRename, alignRight,
   } = props;
 
-
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const bookmarkItemClickedHandler = useCallback(async() => {
     if (!isIPageInfoForOperation(pageInfo) || onClickBookmarkMenuItem == null) {
@@ -136,7 +140,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
     // PathRecovery
     // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
-    const shouldShowPathRecoveryButton = operationProcessData?.Rename != null ? operationProcessData?.Rename.isProcessable : false;
+    const shouldShowPathRecoveryButton = operationProcessData != null ? shouldRecoverPagePaths(operationProcessData) : false;
 
     contents = (
       <>
@@ -148,8 +152,15 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           </DropdownItem>
         ) }
 
+        { AdditionalMenuItemsOnTop && (
+          <>
+            <AdditionalMenuItemsOnTop pageInfo={pageInfo} />
+            <DropdownItem divider />
+          </>
+        ) }
+
         {/* Bookmark */}
-        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
+        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && isIPageInfoForOperation(pageInfo) && (
           <DropdownItem
             onClick={bookmarkItemClickedHandler}
             className="grw-page-control-dropdown-item"
@@ -253,9 +264,8 @@ type PageItemControlSubstanceProps = CommonProps & {
 export const PageItemControlSubstance = (props: PageItemControlSubstanceProps): JSX.Element => {
 
   const {
-    pageId, pageInfo: presetPageInfo, fetchOnInit,
-    children,
-    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickPathRecoveryMenuItem,
+    pageId, pageInfo: presetPageInfo, fetchOnInit, children, onClickBookmarkMenuItem, onClickRenameMenuItem,
+    onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickPathRecoveryMenuItem,
   } = props;
 
   const [isOpen, setIsOpen] = useState(false);

+ 3 - 0
packages/app/src/components/CommonStyles/katex.module.scss

@@ -0,0 +1,3 @@
+.katex-container :global {
+  @import '~katex/dist/katex.min';
+}

+ 3 - 3
packages/app/src/components/DescendantsPageList.tsx

@@ -103,7 +103,7 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
     );
   }
 
-  const showPager = pagingResult.items.length > pagingResult.limit;
+  const showPager = pagingResult.totalCount > pagingResult.limit;
 
   return (
     <>
@@ -130,11 +130,11 @@ export const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element
   );
 };
 
-type Props = {
+export type DescendantsPageListProps = {
   path: string,
 }
 
-export const DescendantsPageList = (props: Props): JSX.Element => {
+export const DescendantsPageList = (props: DescendantsPageListProps): JSX.Element => {
   const { path } = props;
 
   const [activePage, setActivePage] = useState(1);

+ 17 - 3
packages/app/src/components/DescendantsPageListModal.tsx

@@ -2,6 +2,7 @@
 import React, { useState, useMemo } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
 import {
   Modal, ModalHeader, ModalBody,
 } from 'reactstrap';
@@ -11,12 +12,20 @@ import { useDescendantsPageListModal } from '~/stores/modal';
 
 import { CustomNavTab } from './CustomNavigation/CustomNav';
 import CustomTabContent from './CustomNavigation/CustomTabContent';
-import { DescendantsPageList } from './DescendantsPageList';
+import { DescendantsPageListProps } from './DescendantsPageList';
 import ExpandOrContractButton from './ExpandOrContractButton';
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
-import { PageTimeline } from './PageTimeline';
 
+const DescendantsPageList = (props: DescendantsPageListProps): JSX.Element => {
+  const DescendantsPageList = dynamic<DescendantsPageListProps>(() => import('./DescendantsPageList').then(mod => mod.DescendantsPageList), { ssr: false });
+  return <DescendantsPageList {...props}/>;
+};
+
+const PageTimeline = (): JSX.Element => {
+  const PageTimeline = dynamic(() => import('./PageTimeline').then(mod => mod.PageTimeline), { ssr: false });
+  return <PageTimeline />;
+};
 
 export const DescendantsPageListModal = (): JSX.Element => {
   const { t } = useTranslation();
@@ -44,7 +53,12 @@ export const DescendantsPageListModal = (): JSX.Element => {
       },
       timeline: {
         Icon: TimeLineIcon,
-        Content: () => <PageTimeline />,
+        Content: () => {
+          if (status == null || !status.isOpened) {
+            return <></>;
+          }
+          return <PageTimeline />;
+        },
         i18n: t('Timeline View'),
         index: 1,
         isLinkEnabled: () => !isSharedUser,

+ 5 - 0
packages/app/src/components/IdenticalPathPage.module.scss

@@ -0,0 +1,5 @@
+@use '~/styles/molecules/page-accessories-control';
+
+.grw-page-accessories-control :global {
+  @extend %grw-page-accessories-control;
+}

+ 4 - 1
packages/app/src/components/IdenticalPathPage.tsx

@@ -11,6 +11,9 @@ import PageListIcon from './Icons/PageListIcon';
 import { PageListItemL } from './PageList/PageListItemL';
 
 
+import styles from './IdenticalPathPage.module.scss';
+
+
 type IdenticalPathAlertProps = {
   path? : string | null,
 }
@@ -67,7 +70,7 @@ export const IdenticalPathPage = (): JSX.Element => {
     <div className="d-flex flex-column flex-lg-row-reverse">
 
       <div className="grw-side-contents-container">
-        <div className="grw-page-accessories-control pb-1">
+        <div className={`pb-1 grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
           { currentPath != null && !isSharedUser && (
             <button
               type="button"

+ 23 - 27
packages/app/src/components/InstallerForm.jsx

@@ -1,11 +1,12 @@
 import React from 'react';
 
 import i18next from 'i18next';
-import { useTranslation } from 'next-i18next';
+
+import { useTranslation, i18n } from 'next-i18next';
 import PropTypes from 'prop-types';
 
-// import { localeMetadatas } from '~/client/util/i18n';
 import { useCsrfToken } from '~/stores/context';
+import { i18n as i18nConfig } from '^/config/next-i18next.config';
 
 class InstallerForm extends React.Component {
 
@@ -15,21 +16,12 @@ class InstallerForm extends React.Component {
     this.state = {
       isValidUserName: true,
       isSubmittingDisabled: false,
-      selectedLang: {},
     };
     this.checkUserName = this.checkUserName.bind(this);
 
     this.submitHandler = this.submitHandler.bind(this);
   }
 
-  // UNSAFE_componentWillMount() {
-  //   const meta = localeMetadatas.find(v => v.id === i18next.language);
-  //   if (meta == null) {
-  //     return this.setState({ selectedLang: localeMetadatas[0] });
-  //   }
-  //   this.setState({ selectedLang: meta });
-  // }
-
   checkUserName(event) {
     const axios = require('axios').create({
       headers: {
@@ -44,7 +36,6 @@ class InstallerForm extends React.Component {
 
   changeLanguage(meta) {
     i18next.changeLanguage(meta.id);
-    this.setState({ selectedLang: meta });
   }
 
   submitHandler() {
@@ -59,6 +50,7 @@ class InstallerForm extends React.Component {
   }
 
   render() {
+    const { t } = this.props;
     const hasErrorClass = this.state.isValidUserName ? '' : ' has-error';
     const unavailableUserId = this.state.isValidUserName
       ? ''
@@ -89,29 +81,33 @@ class InstallerForm extends React.Component {
                   aria-expanded="true"
                 >
                   <span className="float-left">
-                    {this.state.selectedLang.displayName}
+                    {t('meta.display_name')}
                   </span>
                 </button>
                 <input
                   type="hidden"
-                  value={this.state.selectedLang.id}
                   name="registerForm[app:globalLang]"
                 />
-                {/* <div className="dropdown-menu" aria-labelledby="dropdownLanguage">
+                <div className="dropdown-menu" aria-labelledby="dropdownLanguage">
                   {
-                    localeMetadatas.map(meta => (
-                      <button
-                        key={meta.id}
-                        data-testid={`dropdownLanguageMenu-${meta.id}`}
-                        className="dropdown-item"
-                        type="button"
-                        onClick={() => { this.changeLanguage(meta) }}
-                      >
-                        {meta.displayName}
-                      </button>
-                    ))
+                    i18nConfig.locales.map((locale) => {
+                      const fixedT = i18n.getFixedT(locale);
+                      i18n.loadLanguages(i18nConfig.locales);
+
+                      return (
+                        <button
+                          key={locale}
+                          data-testid={`dropdownLanguageMenu-${locale}`}
+                          className="dropdown-item"
+                          type="button"
+                          onClick={() => { this.changeLanguage(locale) }}
+                        >
+                          {fixedT('meta.display_name')}
+                        </button>
+                      );
+                    })
                   }
-                </div> */}
+                </div>
               </div>
             </div>
 

+ 3 - 0
packages/app/src/components/Layout/Admin.module.scss

@@ -227,6 +227,9 @@ $slack-work-space-name-card-border: #efc1f6;
       max-height: 500px;
       overflow-y: auto;
     }
+    .date-range-picker {
+      width: 188px;
+    }
   }
 
   #layoutOptions {

+ 3 - 1
packages/app/src/components/Me/BasicInfoSettings.tsx

@@ -4,7 +4,7 @@ import { useTranslation } from 'next-i18next';
 
 import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import { localeMetadatas } from '~/client/util/i18n';
+// import { localeMetadatas } from '~/client/util/i18n';
 import { usePersonalSettings } from '~/stores/personal-settings';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
@@ -113,6 +113,7 @@ const BasicInfoSettings = (props: Props) => {
       <div className="form-group row">
         <label className="text-left text-md-right col-md-3 col-form-label">{t('Language')}</label>
         <div className="col-md-6">
+          {/*
           {
             localeMetadatas.map(meta => (
               <div key={meta.id} className="custom-control custom-radio custom-control-inline">
@@ -128,6 +129,7 @@ const BasicInfoSettings = (props: Props) => {
               </div>
             ))
           }
+          */}
         </div>
       </div>
       <div className="form-group row">

+ 31 - 0
packages/app/src/components/Navbar/AuthorInfo.module.scss

@@ -0,0 +1,31 @@
+@use '~/styles/bootstrap/init' as bs;
+
+$author-font-size: 12px;
+$date-font-size: 11px;
+
+.grw-author-info :global {
+  li {
+    font-size: $author-font-size;
+    list-style: none;
+  }
+
+  .text-date {
+    font-size: $date-font-size;
+  }
+
+  .picture {
+    width: 22px;
+    height: 22px;
+    border: 1px solid bs.$gray-300;
+
+    &.picture-xs {
+      width: 14px;
+      height: 14px;
+    }
+  }
+}
+
+.grw-author-info-skelton :global {
+  width: 139px;
+  height: calc((#{$author-font-size} + #{$date-font-size}) * #{bs.$line-height-base});
+}

+ 3 - 1
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -39,6 +39,8 @@ import { Skelton } from '../Skelton';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
 import { SubNavButtonsProps } from './SubNavButtons';
 
+import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
+
 
 type AdditionalMenuItemsProps = {
   pageId: string,
@@ -155,7 +157,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
   const PageEditorModeManager = dynamic(
     () => import('./PageEditorModeManager'),
-    { ssr: false, loading: () => <Skelton width={213} height={33.99} /> },
+    { ssr: false, loading: () => <Skelton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skelton']}`} /> },
   );
   const SubNavButtons = dynamic<SubNavButtonsProps>(
     () => import('./SubNavButtons').then(mod => mod.SubNavButtons),

+ 10 - 32
packages/app/src/components/Navbar/GrowiSubNavigation.module.scss

@@ -88,42 +88,20 @@
       font-size: 16px;
     }
 
-    ul.authors {
-      li {
-        font-size: 12px;
-        list-style: none;
-      }
+    .user-list-popover {
+      max-width: 200px;
 
-      .text-date {
-        font-size: 11px;
-      }
+      .user-list-content {
+        direction: rtl;
 
-      .picture {
-        width: 22px;
-        height: 22px;
-        border: 1px solid bs.$gray-300;
-
-        &.picture-xs {
-          width: 14px;
-          height: 14px;
+        .liker-user-count,
+        .seen-user-count {
+          font-size: 12px;
+          font-weight: bolder;
         }
       }
-
-      .user-list-popover {
-        max-width: 200px;
-
-        .user-list-content {
-          direction: rtl;
-
-          .liker-user-count,
-          .seen-user-count {
-            font-size: 12px;
-            font-weight: bolder;
-          }
-        }
-        .cls-1 {
-          isolation: isolate;
-        }
+      .cls-1 {
+        isolation: isolate;
       }
     }
   }

+ 13 - 5
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -14,10 +14,12 @@ import { Skelton } from '../Skelton';
 import DrawerToggler from './DrawerToggler';
 
 
+import TagLabelsStyles from '../Page/TagLabels.module.scss';
+import AuthorInfoStyles from './AuthorInfo.module.scss';
 import styles from './GrowiSubNavigation.module.scss';
 
 
-type Props = {
+export type GrowiSubNavigationProps = {
   page: Partial<IPageHasId>,
 
   showDrawerToggler?: boolean,
@@ -35,10 +37,16 @@ type Props = {
   additionalClasses?: string[],
 }
 
-export const GrowiSubNavigation = (props: Props): JSX.Element => {
+export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element => {
 
-  const TagLabels = dynamic(() => import('../Page/TagLabels'), { ssr: false, loading: () => <Skelton width={137} height={21.99} additionalClass='py-1' /> });
-  const AuthorInfo = dynamic(() => import('./AuthorInfo'), { ssr: false, loading: () => <Skelton width={139} height={32.84} additionalClass='py-1' /> });
+  const TagLabels = dynamic(() => import('../Page/TagLabels'), {
+    ssr: false,
+    loading: () => <Skelton additionalClass={`${TagLabelsStyles['grw-tag-labels-skelton']} py-1`} />,
+  });
+  const AuthorInfo = dynamic(() => import('./AuthorInfo'), {
+    ssr: false,
+    loading: () => <Skelton additionalClass={`${AuthorInfoStyles['grw-author-info-skelton']} py-1`} />,
+  });
 
   const { data: editorMode } = useEditorMode();
 
@@ -95,7 +103,7 @@ export const GrowiSubNavigation = (props: Props): JSX.Element => {
 
         {/* Page Authors */}
         { (showPageAuthors && !isCompactMode) && (
-          <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3">
+          <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
             <li className="pb-1">
               <AuthorInfo user={creator as IUser} date={createdAt} locate="subnav" />
             </li>

+ 36 - 31
packages/app/src/components/Navbar/GrowiSubNavigationSwitcher.jsx

@@ -33,7 +33,12 @@ const GrowiSubNavigationSwitcher = (props) => {
   const [width, setWidth] = useState(null);
 
   const fixedContainerRef = useRef();
-  const stickyEvents = useMemo(() => new StickyEvents({ stickySelector: '#grw-subnav-sticky-trigger' }), []);
+  /*
+  * Comment out to prevent err >>> TypeError: Cannot read properties of null (reading 'bottom')
+  * The above err occurs when moving to admin page after rendering normal pages.
+  * This is because id "grw-subnav-sticky-trigger" does not exist on admin pages.
+  */
+  // const stickyEvents = useMemo(() => new StickyEvents({ stickySelector: '#grw-subnav-sticky-trigger' }), []);
 
   const initWidth = useCallback(() => {
     const instance = fixedContainerRef.current;
@@ -48,18 +53,18 @@ const GrowiSubNavigationSwitcher = (props) => {
     setWidth(clientWidth);
   }, []);
 
-  const initVisible = useCallback(() => {
-    const elements = stickyEvents.stickyElements;
+  // const initVisible = useCallback(() => {
+  //   const elements = stickyEvents.stickyElements;
 
-    for (const elem of elements) {
-      const bool = stickyEvents.isSticking(elem);
-      if (bool) {
-        setVisible(bool);
-        break;
-      }
-    }
+  //   for (const elem of elements) {
+  //     const bool = stickyEvents.isSticking(elem);
+  //     if (bool) {
+  //       setVisible(bool);
+  //       break;
+  //     }
+  //   }
 
-  }, [stickyEvents]);
+  // }, [stickyEvents]);
 
   // setup effect by resizing event
   useEffect(() => {
@@ -78,19 +83,19 @@ const GrowiSubNavigationSwitcher = (props) => {
     setVisible(event.detail.isSticky);
   }, []);
 
-  // setup effect by sticky event
-  useEffect(() => {
-    // sticky
-    // See: https://github.com/ryanwalters/sticky-events
-    const { stickySelector } = stickyEvents;
-    const elem = document.querySelector(stickySelector);
-    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+  // // setup effect by sticky event
+  // useEffect(() => {
+  //   // sticky
+  //   // See: https://github.com/ryanwalters/sticky-events
+  //   const { stickySelector } = stickyEvents;
+  //   const elem = document.querySelector(stickySelector);
+  //   elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
 
-    // return clean up handler
-    return () => {
-      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-    };
-  }, [stickyChangeHandler, stickyEvents]);
+  //   // return clean up handler
+  //   return () => {
+  //     elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+  //   };
+  // }, [stickyChangeHandler, stickyEvents]);
 
   // update width when sidebar collapsing changed
   useEffect(() => {
@@ -99,16 +104,16 @@ const GrowiSubNavigationSwitcher = (props) => {
     }
   }, [isSidebarCollapsed, initWidth]);
 
-  // initialize
-  useEffect(() => {
-    initWidth();
+  // // initialize
+  // useEffect(() => {
+  //   initWidth();
 
-    // check sticky state several times
-    setTimeout(initVisible, 100);
-    setTimeout(initVisible, 300);
-    setTimeout(initVisible, 2000);
+  //   // check sticky state several times
+  //   setTimeout(initVisible, 100);
+  //   setTimeout(initVisible, 300);
+  //   setTimeout(initVisible, 2000);
 
-  }, [initWidth, initVisible]);
+  // }, [initWidth, initVisible]);
 
   // ${styles['grw-subnav-switcher']}
 

+ 10 - 2
packages/app/src/components/Navbar/PageEditorModeManager.module.scss

@@ -2,6 +2,8 @@
 @use '~/styles/bootstrap/init' as bs;
 @use '~/styles/mixins';
 
+$btn-line-height: 1.2rem;
+
 .grw-page-editor-mode-manager :global {
   .btn {
     width: 70px;
@@ -11,7 +13,7 @@
 
     &.view-button,
     &.edit-button {
-      line-height: 1.2rem;
+      line-height: $btn-line-height;
       .grw-page-editor-mode-manager-icon {
         @include bs.media-breakpoint-down(sm) {
           font-size: 1.2rem;
@@ -19,7 +21,7 @@
       }
     }
     &.hackmd-button {
-      line-height: 1.2rem;
+      line-height: $btn-line-height;
       .grw-page-editor-mode-manager-icon {
         @include bs.media-breakpoint-down(sm) {
           font-size: 1.2rem;
@@ -32,3 +34,9 @@
     }
   }
 }
+
+.grw-page-editor-mode-manager-skelton :global {
+
+  width: 213px;
+  height: calc($btn-line-height + bs.$btn-padding-y*2 + bs.$btn-border-width*2);
+}

+ 71 - 6
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -1,10 +1,14 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useMemo } from 'react';
 
-import dynamic from 'next/dynamic';
+import { useTranslation } from 'next-i18next';
+import { DropdownItem } from 'reactstrap';
 
-import { toggleBookmark, toggleLike, toggleSubscribe } from '~/client/services/page-operation';
 import {
-  IPageInfoAll, IPageToDeleteWithMeta, IPageToRenameWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
+  toggleBookmark, toggleLike, toggleSubscribe, updateContentWidth,
+} from '~/client/services/page-operation';
+import { toastError } from '~/client/util/apiNotification';
+import {
+  IPageInfoForOperation, IPageToDeleteWithMeta, IPageToRenameWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
 } from '~/interfaces/page';
 import { useIsGuestUser } from '~/stores/context';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
@@ -22,6 +26,43 @@ import SubscribeButton from '../SubscribeButton';
 import SeenUserInfo from '../User/SeenUserInfo';
 
 
+type WideViewMenuItemProps = AdditionalMenuItemsRendererProps & {
+  onClickMenuItem: (newValue: boolean) => void,
+}
+
+const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    pageInfo, onClickMenuItem,
+  } = props;
+
+  if (!isIPageInfoForEntity(pageInfo)) {
+    return <></>;
+  }
+
+  return (
+    <DropdownItem
+      onClick={() => onClickMenuItem(!pageInfo.expandContentWidth)}
+      className="grw-page-control-dropdown-item"
+    >
+      <div className="custom-control custom-switch ml-1">
+        <input
+          id="switchContentWidth"
+          className="custom-control-input"
+          type="checkbox"
+          checked={pageInfo.expandContentWidth}
+          onChange={() => {}}
+        />
+        <label className="custom-control-label" htmlFor="switchContentWidth">
+          { t('wide_view') }
+        </label>
+      </div>
+    </DropdownItem>
+  );
+};
+
+
 type CommonProps = {
   isCompactMode?: boolean,
   disableSeenUserInfoPopover?: boolean,
@@ -38,7 +79,7 @@ type SubNavButtonsSubstanceProps = CommonProps & {
   shareLinkId?: string | null,
   revisionId: string | null,
   path?: string | null,
-  pageInfo: IPageInfoAll,
+  pageInfo: IPageInfoForOperation,
 }
 
 const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element => {
@@ -143,11 +184,34 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     onClickDeleteMenuItem(pageToDelete);
   }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
 
+  const switchContentWidthClickHandler = useCallback(async(newValue: boolean) => {
+    if (isGuestUser == null || isGuestUser) {
+      return;
+    }
+    if (!isIPageInfoForEntity(pageInfo)) {
+      return;
+    }
+    try {
+      await updateContentWidth(pageId, newValue);
+      mutatePageInfo();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
+
+  const additionalMenuItemOnTopRenderer = useMemo(() => {
+    if (!isIPageInfoForEntity(pageInfo)) {
+      return undefined;
+    }
+    const wideviewMenuItemRenderer = (props: WideViewMenuItemProps) => <WideViewMenuItem {...props} onClickMenuItem={switchContentWidthClickHandler} />;
+    return wideviewMenuItemRenderer;
+  }, [pageInfo, switchContentWidthClickHandler]);
+
   if (!isIPageInfoForOperation(pageInfo)) {
     return <></>;
   }
 
-
   const {
     sumOfLikers, sumOfSeenUsers, isLiked, bookmarkCount, isBookmarked,
   } = pageInfo;
@@ -195,6 +259,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
           pageInfo={pageInfo}
           isEnableActions={!isGuestUser}
           forceHideMenuItems={forceHideMenuItemsWithBookmark}
+          additionalMenuItemOnTopRenderer={additionalMenuItemOnTopRenderer}
           additionalMenuItemRenderer={additionalMenuItemRenderer}
           onClickRenameMenuItem={renameMenuItemClickHandler}
           onClickDuplicateMenuItem={duplicateMenuItemClickHandler}

+ 8 - 6
packages/app/src/components/Page.jsx

@@ -1,14 +1,13 @@
 import React, {
-  useCallback, useEffect, useMemo, useRef, useState,
+  useEffect, useRef, useState,
 } from 'react';
 
 import dynamic from 'next/dynamic';
 import PropTypes from 'prop-types';
-import { debounce } from 'throttle-debounce';
+// import { debounce } from 'throttle-debounce';
 
-import MarkdownTable from '~/client/models/MarkdownTable';
 import { blinkSectionHeaderAtBoot } from '~/client/util/blink-section-header';
-import { getOptionsToSave } from '~/client/util/editor';
+// import { getOptionsToSave } from '~/client/util/editor';
 import {
   useIsGuestUser, useIsBlinkedHeaderAtBoot,
 } from '~/stores/context';
@@ -23,8 +22,11 @@ import {
 import loggerFactory from '~/utils/logger';
 
 import RevisionRenderer from './Page/RevisionRenderer';
-import mdu from './PageEditor/MarkdownDrawioUtil';
-import mtu from './PageEditor/MarkdownTableUtil';
+
+// TODO: import dynamically
+// import MarkdownTable from '~/client/models/MarkdownTable';
+// import mdu from './PageEditor/MarkdownDrawioUtil';
+// import mtu from './PageEditor/MarkdownTableUtil';
 
 const logger = loggerFactory('growi:Page');
 

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

@@ -0,0 +1,5 @@
+@use '~/styles/molecules/page-accessories-control';
+
+.grw-page-accessories-control :global {
+  @extend %grw-page-accessories-control;
+}

+ 6 - 8
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -5,7 +5,7 @@ import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { TabContent, TabPane } from 'reactstrap';
 
-import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+// import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import {
   useCurrentPagePath, useIsSharedUser, useIsEditable, useIsUserPage, usePageUser, useShareLinkId, useIsNotFound, useIsNotCreatable,
 } from '~/stores/context';
@@ -15,13 +15,13 @@ import { EditorMode, useEditorMode } from '~/stores/ui';
 
 import CountBadge from '../Common/CountBadge';
 import PageListIcon from '../Icons/PageListIcon';
-import NotFoundPage from '../NotFoundPage';
 import { Page } from '../Page';
 // import PageEditorByHackmd from '../PageEditorByHackmd';
 import TableOfContents from '../TableOfContents';
 import UserInfo from '../User/UserInfo';
 
-import styles from '../TableOfContents.module.scss';
+
+import styles from './DisplaySwitcher.module.scss';
 
 
 const WIKI_HEADER_LINK = 120;
@@ -77,7 +77,7 @@ const DisplaySwitcher = (): JSX.Element => {
                 <div className="grw-side-contents-sticky-container">
 
                   {/* Page list */}
-                  <div className="grw-page-accessories-control">
+                  <div className={`grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
                     { currentPagePath != null && !isSharedUser && (
                       <button
                         type="button"
@@ -97,7 +97,7 @@ const DisplaySwitcher = (): JSX.Element => {
                   {/* Comments */}
                   {/* { getCommentListDom != null && !isTopPagePath && ( */}
                   { !isTopPagePath && (
-                    <div className="grw-page-accessories-control mt-2">
+                    <div className={`mt-2 grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
                       <button
                         type="button"
                         className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
@@ -111,9 +111,7 @@ const DisplaySwitcher = (): JSX.Element => {
                   ) }
 
                   <div className="d-none d-lg-block">
-                    <div id="revision-toc" className={`revision-toc ${styles['revision-toc']}`}>
-                      <TableOfContents />
-                    </div>
+                    <TableOfContents />
                     <ContentLinkButtons />
                   </div>
 

+ 1 - 1
packages/app/src/components/Page/RenderTagLabels.tsx

@@ -35,7 +35,7 @@ const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
 
       <div id="edit-tags-btn-wrapper-for-tooltip">
         <a
-          className={`btn btn-link btn-edit-tags p-0 text-muted ${isTagsEmpty ? 'no-tags' : ''} ${isGuestUser ? 'disabled' : ''}`}
+          className={`btn btn-link btn-edit-tags p-0 text-muted d-flex ${isTagsEmpty ? 'no-tags' : ''} ${isGuestUser ? 'disabled' : ''}`}
           onClick={openEditorHandler}
         >
           { isTagsEmpty && <>{ t('Add tags for this page') }</>}

+ 76 - 1
packages/app/src/components/Page/RevisionRenderer.tsx

@@ -13,6 +13,8 @@ import loggerFactory from '~/utils/logger';
 
 // import RevisionBody from './RevisionBody';
 
+import katexStyles from '../CommonStyles/katex.module.scss';
+
 
 const logger = loggerFactory('components:Page:RevisionRenderer');
 
@@ -100,7 +102,10 @@ const RevisionRenderer = (props: Props): JSX.Element => {
   } = props;
 
   return (
-    <ReactMarkdown {...rendererOptions} className={`wiki ${additionalClassName ?? ''}`}>
+    <ReactMarkdown
+      {...rendererOptions}
+      className={`wiki katex-container ${katexStyles['katex-container']} ${additionalClassName ?? ''}`}
+    >
       {markdown}
     </ReactMarkdown>
   );
@@ -122,6 +127,76 @@ const RevisionRenderer = (props: Props): JSX.Element => {
   // }, [editorSettings?.renderDrawioInRealtime, markdown, pagePath]);
 
 
+  // const renderHtml = useCallback(async() => {
+  //   if (interceptorManager == null) {
+  //     return;
+  //   }
+
+  //   const context = currentRenderingContext;
+
+  //   await interceptorManager.process('preRender', context);
+  //   await interceptorManager.process('prePreProcess', context);
+  //   context.markdown = growiRenderer.preProcess(context.markdown, context);
+  //   await interceptorManager.process('postPreProcess', context);
+  //   context.parsedHTML = growiRenderer.process(context.markdown, context);
+  //   await interceptorManager.process('prePostProcess', context);
+  //   context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
+
+  //   const isMarkdownEmpty = context.markdown.trim().length === 0;
+  //   if (highlightKeywords != null && !isMarkdownEmpty) {
+  //     context.parsedHTML = getHighlightedBody(context.parsedHTML, highlightKeywords);
+  //   }
+  //   await interceptorManager.process('postPostProcess', context);
+  //   await interceptorManager.process('preRenderHtml', context);
+
+  //   setHtml(context.parsedHTML);
+  // }, [currentRenderingContext, growiRenderer, highlightKeywords, interceptorManager]);
+
+  // useEffect(() => {
+  //   if (interceptorManager == null) {
+  //     return;
+  //   }
+
+  //   renderHtml()
+  //     .then(() => {
+  //       // const HeaderLink = document.getElementsByClassName('revision-head-link');
+  //       // const HeaderLinkArray = Array.from(HeaderLink);
+  //       // addSmoothScrollEvent(HeaderLinkArray as HTMLAnchorElement[], blinkElem);
+
+  //       // interceptorManager.process('postRenderHtml', currentRenderingContext);
+  //     });
+
+  // }, [currentRenderingContext, interceptorManager, renderHtml]);
+
+  // const config = props.appContainer.getConfig();
+  // const isMathJaxEnabled = !!config.env.MATHJAX;
+
+  // return (
+  //   <RevisionBody
+  //     html={html}
+  //     isMathJaxEnabled={isMathJaxEnabled}
+  //     additionalClassName={props.additionalClassName}
+  //     renderMathJaxOnInit
+  //   />
+  // );
+
+  // const [html, setHtml] = useState('');
+
+  // const { data: interceptorManager } = useInterceptorManager();
+  // const { data: editorSettings } = useEditorSettings();
+  // const { data: currentPathname } = useCurrentPathname();
+
+  // const currentRenderingContext = useMemo(() => {
+  //   return {
+  //     markdown,
+  //     parsedHTML: '',
+  //     pagePath,
+  //     renderDrawioInRealtime: editorSettings?.renderDrawioInRealtime,
+  //     currentPathname: decodeURIComponent(currentPathname ?? '/'),
+  //   };
+  // }, [editorSettings?.renderDrawioInRealtime, markdown, pagePath]);
+
+
   // const renderHtml = useCallback(async() => {
   //   if (interceptorManager == null) {
   //     return;

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

@@ -0,0 +1,18 @@
+@use '~/styles/bootstrap/init' as bs;
+
+$grw-tag-label-font-size: 12px;
+
+.grw-tag-labels :global {
+  .grw-tag-label {
+    font-size: $grw-tag-label-font-size;
+    font-weight: normal;
+    border-radius: bs.$border-radius;
+  }
+}
+
+
+.grw-tag-labels-skelton :global {
+  width: 137px;
+  height: calc(#{$grw-tag-label-font-size} + #{bs.$badge-padding-y} * 2);
+  font-size: $grw-tag-label-font-size; // set font-size to use the same em value in bs.$badge-padding-y(https://getbootstrap.jp/docs/5.0/components/badge/#variables)
+}

+ 4 - 2
packages/app/src/components/Page/TagLabels.tsx

@@ -3,6 +3,8 @@ import React, { FC, useState } from 'react';
 import RenderTagLabels from './RenderTagLabels';
 import TagEditModal from './TagEditModal';
 
+import styles from './TagLabels.module.scss';
+
 type Props = {
   tags?: string[],
   isGuestUser: boolean,
@@ -25,7 +27,7 @@ const TagLabels:FC<Props> = (props: Props) => {
 
   return (
     <>
-      <form className="grw-tag-labels form-inline">
+      <div className={`${styles['grw-tag-labels']} grw-tag-labels d-flex align-items-center`}>
         <i className="tag-icon icon-tag mr-2"></i>
         { tags == null
           ? (
@@ -39,7 +41,7 @@ const TagLabels:FC<Props> = (props: Props) => {
             />
           )
         }
-      </form>
+      </div>
 
       <TagEditModal
         tags={tags}

+ 0 - 6
packages/app/src/styles/_page-accessories-modal.scss → packages/app/src/components/PageAccessoriesModal.module.scss

@@ -16,9 +16,3 @@
     margin-bottom: 0rem;
   }
 }
-
-// revision-history
-// to stay d2h-code-side-line-number in the revision history diff area
-.d2h-wrapper {
-  position: relative;
-}

+ 2 - 1
packages/app/src/components/PageAccessoriesModal.tsx

@@ -21,6 +21,7 @@ import PageHistory from './PageHistory';
 import ShareLink from './ShareLink/ShareLink';
 import { withUnstatedContainers } from './UnstatedUtils';
 
+import styles from './PageAccessoriesModal.module.scss';
 
 type Props = {
   appContainer: AppContainer,
@@ -107,7 +108,7 @@ const PageAccessoriesModal = (props: Props): JSX.Element => {
       isOpen={isOpened}
       toggle={close}
       data-testid="page-accessories-modal"
-      className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
+      className={`grw-page-accessories-modal ${styles['grw-page-accessories-modal']} ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
     >
       <ModalHeader className="p-0" toggle={close} close={buttons}>
         <CustomNavTab

+ 0 - 108
packages/app/src/components/PageAccessoriesModalControl.jsx

@@ -1,108 +0,0 @@
-import React, { Fragment, useMemo } from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-import { UncontrolledTooltip } from 'reactstrap';
-
-import { useCurrentPageId } from '~/stores/context';
-
-import AttachmentIcon from './Icons/AttachmentIcon';
-import HistoryIcon from './Icons/HistoryIcon';
-import PageListIcon from './Icons/PageListIcon';
-import ShareLinkIcon from './Icons/ShareLinkIcon';
-import TimeLineIcon from './Icons/TimeLineIcon';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-
-const PageAccessoriesModalControl = (props) => {
-  const { t } = useTranslation();
-  const {
-    pageAccessoriesContainer, isGuestUser, isSharedUser,
-  } = props;
-  const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
-
-  const { data: pageId } = useCurrentPageId();
-
-  const accessoriesBtnList = useMemo(() => {
-    return [
-      {
-        name: 'pagelist',
-        Icon: <PageListIcon />,
-        disabled: isSharedUser,
-        i18n: t('page_list'),
-      },
-      {
-        name: 'timeline',
-        Icon: <TimeLineIcon />,
-        disabled: isSharedUser,
-        i18n: t('Timeline View'),
-      },
-      {
-        name: 'pageHistory',
-        Icon: <HistoryIcon />,
-        disabled: isGuestUser || isSharedUser,
-        i18n: t('History'),
-      },
-      {
-        name: 'attachment',
-        Icon: <AttachmentIcon />,
-        i18n: t('attachment_data'),
-      },
-      {
-        name: 'shareLink',
-        Icon: <ShareLinkIcon />,
-        disabled: isGuestUser || isSharedUser || isLinkSharingDisabled,
-        i18n: t('share_links.share_link_management'),
-      },
-    ];
-  }, [t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
-
-  return (
-    <div className="grw-page-accessories-control d-flex flex-nowrap align-items-center justify-content-end justify-content-lg-between">
-      {accessoriesBtnList.map((accessory) => {
-
-        let tooltipMessage;
-        if (accessory.disabled) {
-          tooltipMessage = t('Not available for guest');
-          if (accessory.name === 'shareLink' && isLinkSharingDisabled) {
-            tooltipMessage = t('Link sharing is disabled');
-          }
-        }
-        else {
-          tooltipMessage = accessory.i18n;
-        }
-
-        return (
-          <Fragment key={accessory.name}>
-            <div id={`shareLink-btn-wrapper-for-tooltip-for-${accessory.name}`}>
-              <button
-                type="button"
-                className={`btn btn-link grw-btn-page-accessories ${accessory.disabled ? 'disabled' : ''}`}
-                onClick={() => pageAccessoriesContainer.openPageAccessoriesModal(accessory.name)}
-              >
-                {accessory.Icon}
-              </button>
-            </div>
-            <UncontrolledTooltip placement="top" target={`shareLink-btn-wrapper-for-tooltip-for-${accessory.name}`} fade={false}>
-              {tooltipMessage}
-            </UncontrolledTooltip>
-          </Fragment>
-        );
-      })}
-    </div>
-  );
-};
-
-PageAccessoriesModalControl.propTypes = {
-  pageAccessoriesContainer: PropTypes.any,
-
-  isGuestUser: PropTypes.bool.isRequired,
-  isSharedUser: PropTypes.bool.isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, []);
-
-export default PageAccessoriesModalControlWrapper;

+ 2 - 4
packages/app/src/components/PageAlert/PageGrantAlert.tsx

@@ -3,15 +3,13 @@ import React from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { useSWRxCurrentPage } from '~/stores/page';
-import { useXss } from '~/stores/xss';
 
 
 export const PageGrantAlert = (): JSX.Element => {
   const { t } = useTranslation();
   const { data: pageData } = useSWRxCurrentPage();
-  const { data: xss } = useXss();
 
-  if (pageData == null || pageData.grant == null || pageData.grant === 1 || xss == null) {
+  if (pageData == null || pageData.grant == null || pageData.grant === 1) {
     return <></>;
   }
 
@@ -34,7 +32,7 @@ export const PageGrantAlert = (): JSX.Element => {
       if (pageData.grant === 5) {
         return (
           <>
-            <i className="icon-fw icon-organization"></i><strong>{xss.process(pageData.grantedGroup.name)} only</strong>
+            <i className="icon-fw icon-organization"></i><strong>{pageData.grantedGroup.name} only</strong>
           </>
         );
       }

+ 1 - 1
packages/app/src/components/PageEditor/DrawioModal.jsx

@@ -136,7 +136,7 @@ class DrawioModal extends React.PureComponent {
         isOpen={this.state.show}
         toggle={this.cancel}
         backdrop="static"
-        className="drawio-modal"
+        className="drawio-modal grw-body-only-modal-expanded"
         size="xl"
         keyboard={false}
       >

+ 0 - 406
packages/app/src/components/PageEditor/Editor.jsx

@@ -1,406 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import Dropzone from 'react-dropzone';
-import {
-  Modal, ModalHeader, ModalBody,
-} from 'reactstrap';
-
-import { useDefaultIndentSize } from '~/stores/context';
-import { useEditorSettings } from '~/stores/editor';
-
-import AbstractEditor from './AbstractEditor';
-import Cheatsheet from './Cheatsheet';
-import CodeMirrorEditor from './CodeMirrorEditor';
-import pasteHelper from './PasteHelper';
-import TextAreaEditor from './TextAreaEditor';
-
-
-class Editor extends AbstractEditor {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isComponentDidMount: false,
-      dropzoneActive: false,
-      isUploading: false,
-      isCheatsheetModalShown: false,
-    };
-
-    this.getEditorSubstance = this.getEditorSubstance.bind(this);
-
-    this.pasteFilesHandler = this.pasteFilesHandler.bind(this);
-
-    this.dragEnterHandler = this.dragEnterHandler.bind(this);
-    this.dragLeaveHandler = this.dragLeaveHandler.bind(this);
-    this.dropHandler = this.dropHandler.bind(this);
-
-    this.showMarkdownHelp = this.showMarkdownHelp.bind(this);
-    this.addAttachmentHandler = this.addAttachmentHandler.bind(this);
-
-    this.getAcceptableType = this.getAcceptableType.bind(this);
-    this.getDropzoneClassName = this.getDropzoneClassName.bind(this);
-    this.renderDropzoneOverlay = this.renderDropzoneOverlay.bind(this);
-  }
-
-  componentDidMount() {
-    this.setState({ isComponentDidMount: true });
-  }
-
-  getEditorSubstance() {
-    return this.props.isMobile
-      ? this.taEditor
-      : this.cmEditor;
-  }
-
-  /**
-   * @inheritDoc
-   */
-  forceToFocus() {
-    this.getEditorSubstance().forceToFocus();
-  }
-
-  /**
-   * @inheritDoc
-   */
-  setValue(newValue) {
-    this.getEditorSubstance().setValue(newValue);
-  }
-
-  /**
-   * @inheritDoc
-   */
-  setGfmMode(bool) {
-    this.getEditorSubstance().setGfmMode(bool);
-  }
-
-  /**
-   * @inheritDoc
-   */
-  setCaretLine(line) {
-    this.getEditorSubstance().setCaretLine(line);
-  }
-
-  /**
-   * @inheritDoc
-   */
-  setScrollTopByLine(line) {
-    this.getEditorSubstance().setScrollTopByLine(line);
-  }
-
-  /**
-   * @inheritDoc
-   */
-  insertText(text) {
-    this.getEditorSubstance().insertText(text);
-  }
-
-  /**
-   * remove overlay and set isUploading to false
-   */
-  terminateUploadingState() {
-    this.setState({
-      dropzoneActive: false,
-      isUploading: false,
-    });
-  }
-
-  /**
-   * dispatch onUpload event
-   */
-  dispatchUpload(files) {
-    if (this.props.onUpload != null) {
-      this.props.onUpload(files);
-    }
-  }
-
-  /**
-   * get acceptable(uploadable) file type
-   */
-  getAcceptableType() {
-    let accept = 'null'; // reject all
-    if (this.props.isUploadable) {
-      if (!this.props.isUploadableFile) {
-        accept = 'image/*'; // image only
-      }
-      else {
-        accept = ''; // allow all
-      }
-    }
-
-    return accept;
-  }
-
-  pasteFilesHandler(event) {
-    const items = event.clipboardData.items || event.clipboardData.files || [];
-
-    // abort if length is not 1
-    if (items.length < 1) {
-      return;
-    }
-
-    for (let i = 0; i < items.length; i++) {
-      try {
-        const file = items[i].getAsFile();
-        // check file type (the same process as Dropzone)
-        if (file != null && pasteHelper.isAcceptableType(file, this.getAcceptableType())) {
-          this.dispatchUpload(file);
-          this.setState({ isUploading: true });
-        }
-      }
-      catch (e) {
-        this.logger.error(e);
-      }
-    }
-  }
-
-  dragEnterHandler(event) {
-    const dataTransfer = event.dataTransfer;
-
-    // do nothing if contents is not files
-    if (!dataTransfer.types.includes('Files')) {
-      return;
-    }
-
-    this.setState({ dropzoneActive: true });
-  }
-
-  dragLeaveHandler() {
-    this.setState({ dropzoneActive: false });
-  }
-
-  dropHandler(accepted, rejected) {
-    // rejected
-    if (accepted.length !== 1) { // length should be 0 or 1 because `multiple={false}` is set
-      this.setState({ dropzoneActive: false });
-      return;
-    }
-
-    const file = accepted[0];
-    this.dispatchUpload(file);
-    this.setState({ isUploading: true });
-  }
-
-  showMarkdownHelp() {
-    this.setState({ isCheatsheetModalShown: true });
-  }
-
-  addAttachmentHandler() {
-    this.dropzone.open();
-  }
-
-  getDropzoneClassName(isDragAccept, isDragReject) {
-    let className = 'dropzone';
-    if (!this.props.isUploadable) {
-      className += ' dropzone-unuploadable';
-    }
-    else {
-      className += ' dropzone-uploadable';
-
-      if (this.props.isUploadableFile) {
-        className += ' dropzone-uploadablefile';
-      }
-    }
-
-    // uploading
-    if (this.state.isUploading) {
-      className += ' dropzone-uploading';
-    }
-
-    if (isDragAccept) {
-      className += ' dropzone-accepted';
-    }
-
-    if (isDragReject) {
-      className += ' dropzone-rejected';
-    }
-
-    return className;
-  }
-
-  renderDropzoneOverlay() {
-    return (
-      <div className="overlay overlay-dropzone-active">
-        {this.state.isUploading
-          && (
-            <span className="overlay-content">
-              <div className="speeding-wheel d-inline-block"></div>
-              <span className="sr-only">Uploading...</span>
-            </span>
-          )
-        }
-        {!this.state.isUploading && <span className="overlay-content"></span>}
-      </div>
-    );
-  }
-
-  renderNavbar() {
-    return (
-      <div className="m-0 navbar navbar-default navbar-editor" style={{ minHeight: 'unset' }}>
-        <ul className="pl-2 nav nav-navbar">
-          { this.getNavbarItems() != null && this.getNavbarItems().map((item, idx) => {
-            // eslint-disable-next-line react/no-array-index-key
-            return <li key={`navbarItem-${idx}`}>{item}</li>;
-          }) }
-        </ul>
-      </div>
-    );
-  }
-
-  getNavbarItems() {
-    // set navbar items(react elements) here that are common in CodeMirrorEditor or TextAreaEditor
-    const navbarItems = [];
-
-    // concat common items and items specific to CodeMirrorEditor or TextAreaEditor
-    return navbarItems.concat(this.getEditorSubstance().getNavbarItems());
-  }
-
-  renderCheatsheetModal() {
-    const hideCheatsheetModal = () => {
-      this.setState({ isCheatsheetModalShown: false });
-    };
-
-    return (
-      <Modal isOpen={this.state.isCheatsheetModalShown} toggle={hideCheatsheetModal} className="modal-gfm-cheatsheet">
-        <ModalHeader tag="h4" toggle={hideCheatsheetModal} className="bg-primary text-light">
-          <i className="icon-fw icon-question" />Markdown help
-        </ModalHeader>
-        <ModalBody>
-          <Cheatsheet />
-        </ModalBody>
-      </Modal>
-    );
-  }
-
-
-  render() {
-    const flexContainer = {
-      height: '100%',
-      display: 'flex',
-      flexDirection: 'column',
-    };
-
-    const {
-      isMobile,
-      indentSize,
-    } = this.props;
-
-    return (
-      <>
-        <div style={flexContainer} className="editor-container">
-          <Dropzone
-            ref={(c) => { this.dropzone = c }}
-            accept={this.getAcceptableType()}
-            noClick
-            noKeyboard
-            multiple={false}
-            onDragLeave={this.dragLeaveHandler}
-            onDrop={this.dropHandler}
-          >
-            {({
-              getRootProps,
-              getInputProps,
-              isDragAccept,
-              isDragReject,
-            }) => {
-              return (
-                <div className={this.getDropzoneClassName(isDragAccept, isDragReject)} {...getRootProps()}>
-                  { this.state.dropzoneActive && this.renderDropzoneOverlay() }
-
-                  { this.state.isComponentDidMount && this.renderNavbar() }
-
-                  {/* for PC */}
-                  { !isMobile && (
-                    // eslint-disable-next-line arrow-body-style
-                    <CodeMirrorEditor
-                      ref={(c) => { this.cmEditor = c }}
-                      indentSize={indentSize}
-                      onPasteFiles={this.pasteFilesHandler}
-                      onDragEnter={this.dragEnterHandler}
-                      onMarkdownHelpButtonClicked={this.showMarkdownHelp}
-                      onAddAttachmentButtonClicked={this.addAttachmentHandler}
-                      {...this.props}
-                    />
-                  )}
-
-                  {/* for mobile */}
-                  { isMobile && (
-                    <TextAreaEditor
-                      ref={(c) => { this.taEditor = c }}
-                      onPasteFiles={this.pasteFilesHandler}
-                      onDragEnter={this.dragEnterHandler}
-                      {...this.props}
-                    />
-                  )}
-
-                  <input {...getInputProps()} />
-                </div>
-              );
-            }}
-          </Dropzone>
-
-          { this.props.isUploadable
-            && (
-              <button
-                type="button"
-                className="btn btn-outline-secondary btn-block btn-open-dropzone"
-                onClick={this.addAttachmentHandler}
-              >
-                <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
-                Attach files
-                <span className="d-none d-sm-inline">
-                &nbsp;by dragging &amp; dropping,&nbsp;
-                  <span className="btn-link">selecting them</span>,&nbsp;
-                  or pasting from the clipboard.
-                </span>
-
-              </button>
-            )
-          }
-
-          { this.renderCheatsheetModal() }
-
-        </div>
-      </>
-    );
-  }
-
-}
-
-Editor.propTypes = Object.assign({
-  noCdn: PropTypes.bool,
-  // this value is markdown
-  value: PropTypes.string,
-  isMobile: PropTypes.bool,
-  isUploadable: PropTypes.bool,
-  isUploadableFile: PropTypes.bool,
-  onChange: PropTypes.func,
-  onUpload: PropTypes.func,
-  editorSettings: PropTypes.object.isRequired,
-  indentSize: PropTypes.number,
-}, AbstractEditor.propTypes);
-
-
-const EditorWrapper = React.forwardRef((props, ref) => {
-  const { data: editorSettings } = useEditorSettings();
-  const { data: defaultIndentSize } = useDefaultIndentSize();
-
-  if (editorSettings == null) {
-    return <></>;
-  }
-
-  return (
-    <Editor
-      ref={ref}
-      {...props}
-      editorSettings={editorSettings}
-      // eslint-disable-next-line react/prop-types
-      indentSize={props.indentSize ?? defaultIndentSize}
-    />
-  );
-});
-
-EditorWrapper.displayName = 'EditorWrapper';
-
-export default EditorWrapper;

+ 363 - 0
packages/app/src/components/PageEditor/Editor.tsx

@@ -0,0 +1,363 @@
+import React, {
+  useState, useRef, useImperativeHandle, useCallback, useMemo,
+} from 'react';
+
+import Dropzone from 'react-dropzone';
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody,
+} from 'reactstrap';
+
+import { toastError } from '~/client/util/apiNotification';
+import { useDefaultIndentSize } from '~/stores/context';
+import { useEditorSettings } from '~/stores/editor';
+import { useIsMobile } from '~/stores/ui';
+
+import { IEditorMethods } from '../../interfaces/editor-methods';
+
+import Cheatsheet from './Cheatsheet';
+import CodeMirrorEditor from './CodeMirrorEditor';
+import pasteHelper from './PasteHelper';
+import TextAreaEditor from './TextAreaEditor';
+
+
+type EditorPropsType = {
+  value?: string,
+  isGfmMode?: boolean,
+  noCdn?: boolean,
+  isUploadable?: boolean,
+  isUploadableFile?: boolean,
+  onChange?: () => void,
+  onUpload?: (file) => void,
+  indentSize?: number,
+  onScrollCursorIntoView?: (line: number) => void,
+  onSave?: () => Promise<void>,
+  onPasteFiles?: (event: Event) => void,
+  onCtrlEnter?: (event: Event) => void,
+}
+
+type DropzoneRef = {
+  open: () => void
+}
+
+const Editor = React.forwardRef((props: EditorPropsType, ref): JSX.Element => {
+  const {
+    onUpload, isUploadable, isUploadableFile, indentSize, isGfmMode = true,
+  } = props;
+
+  const [dropzoneActive, setDropzoneActive] = useState(false);
+  const [isUploading, setIsUploading] = useState(false);
+  const [isCheatsheetModalShown, setIsCheatsheetModalShown] = useState(false);
+
+  const { t } = useTranslation();
+  const { data: editorSettings } = useEditorSettings();
+  const { data: defaultIndentSize } = useDefaultIndentSize();
+  const { data: isMobile } = useIsMobile();
+
+  const dropzoneRef = useRef<DropzoneRef>(null);
+  const cmEditorRef = useRef<CodeMirrorEditor>(null);
+  const taEditorRef = useRef<TextAreaEditor>(null);
+
+  const editorSubstance = isMobile ? taEditorRef.current : cmEditorRef.current;
+
+  const methods: Partial<IEditorMethods> = useMemo(() => {
+    return {
+      forceToFocus: () => {
+        if (editorSubstance == null) { return }
+        editorSubstance.forceToFocus();
+      },
+      setValue: (newValue: string) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.setValue(newValue);
+      },
+      setGfmMode: (bool: boolean) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.setGfmMode(bool);
+      },
+      setCaretLine: (line: number) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.setCaretLine(line);
+      },
+      setScrollTopByLine: (line: number) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.setScrollTopByLine(line);
+      },
+      insertText: (text: string) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.insertText(text);
+      },
+      getNavbarItems: (): JSX.Element[] => {
+        if (editorSubstance == null) { return [] }
+        // concat common items and items specific to CodeMirrorEditor or TextAreaEditor
+        const navbarItems = editorSubstance.getNavbarItems() ?? [];
+        return navbarItems;
+      },
+    };
+  }, [editorSubstance]);
+
+  // methods for ref
+  useImperativeHandle(ref, () => ({
+    forceToFocus: methods.forceToFocus,
+    setValue: methods.setValue,
+    setGfmMode: methods.setGfmMode,
+    setCaretLine: methods.setCaretLine,
+    setScrollTopByLine: methods.setScrollTopByLine,
+    insertText: methods.insertText,
+    /**
+   * remove overlay and set isUploading to false
+   */
+    terminateUploadingState: () => {
+      setDropzoneActive(false);
+      setIsUploading(false);
+    },
+  }));
+
+  /**
+   * dispatch onUpload event
+   */
+  const dispatchUpload = useCallback((files) => {
+    if (onUpload != null) {
+      onUpload(files);
+    }
+  }, [onUpload]);
+
+  /**
+   * get acceptable(uploadable) file type
+   */
+  const getAcceptableType = useCallback(() => {
+    let accept = 'null'; // reject all
+    if (isUploadable) {
+      if (!isUploadableFile) {
+        accept = 'image/*'; // image only
+      }
+      else {
+        accept = ''; // allow all
+      }
+    }
+
+    return accept;
+  }, [isUploadable, isUploadableFile]);
+
+  const pasteFilesHandler = useCallback((event) => {
+    const items = event.clipboardData.items || event.clipboardData.files || [];
+
+    toastError(t('toaster.file_upload_failed'));
+
+    // abort if length is not 1
+    if (items.length < 1) {
+      return;
+    }
+
+    for (let i = 0; i < items.length; i++) {
+      try {
+        const file = items[i].getAsFile();
+        // check file type (the same process as Dropzone)
+        if (file != null && pasteHelper.isAcceptableType(file, getAcceptableType())) {
+          dispatchUpload(file);
+          setIsUploading(true);
+        }
+      }
+      catch (e) {
+        toastError(t('toaster.file_upload_failed'));
+      }
+    }
+  }, [dispatchUpload, getAcceptableType, t]);
+
+  const dragEnterHandler = useCallback((event) => {
+    const dataTransfer = event.dataTransfer;
+
+    // do nothing if contents is not files
+    if (!dataTransfer.types.includes('Files')) {
+      return;
+    }
+
+    setDropzoneActive(true);
+  }, []);
+
+  const dropHandler = useCallback((accepted) => {
+    // rejected
+    if (accepted.length !== 1) { // length should be 0 or 1 because `multiple={false}` is set
+      setDropzoneActive(false);
+      return;
+    }
+
+    const file = accepted[0];
+    dispatchUpload(file);
+    setIsUploading(true);
+  }, [dispatchUpload]);
+
+  const addAttachmentHandler = useCallback(() => {
+    if (dropzoneRef.current == null) { return }
+    dropzoneRef.current.open();
+  }, []);
+
+  const getDropzoneClassName = useCallback((isDragAccept: boolean, isDragReject: boolean) => {
+    let className = 'dropzone';
+    if (!isUploadable) {
+      className += ' dropzone-unuploadable';
+    }
+    else {
+      className += ' dropzone-uploadable';
+
+      if (isUploadableFile) {
+        className += ' dropzone-uploadablefile';
+      }
+    }
+
+    // uploading
+    if (isUploading) {
+      className += ' dropzone-uploading';
+    }
+
+    if (isDragAccept) {
+      className += ' dropzone-accepted';
+    }
+
+    if (isDragReject) {
+      className += ' dropzone-rejected';
+    }
+
+    return className;
+  }, [isUploadable, isUploading, isUploadableFile]);
+
+  const renderDropzoneOverlay = useCallback(() => {
+    return (
+      <div className="overlay overlay-dropzone-active">
+        {isUploading
+          && (
+            <span className="overlay-content">
+              <div className="speeding-wheel d-inline-block"></div>
+              <span className="sr-only">Uploading...</span>
+            </span>
+          )
+        }
+        {!isUploading && <span className="overlay-content"></span>}
+      </div>
+    );
+  }, [isUploading]);
+
+  const renderNavbar = useCallback(() => {
+    return (
+      <div className="m-0 navbar navbar-default navbar-editor" style={{ minHeight: 'unset' }}>
+        <ul className="pl-2 nav nav-navbar">
+          { methods.getNavbarItems?.().map((item, idx) => {
+            // eslint-disable-next-line react/no-array-index-key
+            return <li key={`navbarItem-${idx}`}>{item}</li>;
+          }) }
+        </ul>
+      </div>
+    );
+  }, [methods]);
+
+  const renderCheatsheetModal = useCallback(() => {
+    const hideCheatsheetModal = () => {
+      setIsCheatsheetModalShown(false);
+    };
+
+    return (
+      <Modal isOpen={isCheatsheetModalShown} toggle={hideCheatsheetModal} className="modal-gfm-cheatsheet">
+        <ModalHeader tag="h4" toggle={hideCheatsheetModal} className="bg-primary text-light">
+          <i className="icon-fw icon-question" />Markdown help
+        </ModalHeader>
+        <ModalBody>
+          <Cheatsheet />
+        </ModalBody>
+      </Modal>
+    );
+  }, [isCheatsheetModalShown]);
+
+  if (editorSettings == null) {
+    return <></>;
+  }
+
+  const flexContainer: React.CSSProperties = {
+    height: '100%',
+    display: 'flex',
+    flexDirection: 'column',
+  };
+
+  return (
+    <>
+      <div style={flexContainer} className="editor-container">
+        <Dropzone
+          ref={dropzoneRef}
+          accept={getAcceptableType()}
+          noClick
+          noKeyboard
+          multiple={false}
+          onDragLeave={() => { setDropzoneActive(false) }}
+          onDrop={dropHandler}
+        >
+          {({
+            getRootProps,
+            getInputProps,
+            isDragAccept,
+            isDragReject,
+          }) => {
+            return (
+              <div className={getDropzoneClassName(isDragAccept, isDragReject)} {...getRootProps()}>
+                { dropzoneActive && renderDropzoneOverlay() }
+
+                { renderNavbar() }
+
+                {/* for PC */}
+                { !isMobile && (
+                  // eslint-disable-next-line arrow-body-style
+                  <CodeMirrorEditor
+                    ref={cmEditorRef}
+                    indentSize={indentSize ?? defaultIndentSize}
+                    onPasteFiles={pasteFilesHandler}
+                    onDragEnter={dragEnterHandler}
+                    onMarkdownHelpButtonClicked={() => { setIsCheatsheetModalShown(true) }}
+                    onAddAttachmentButtonClicked={addAttachmentHandler}
+                    editorSettings={editorSettings}
+                    isGfmMode={isGfmMode}
+                    {...props}
+                  />
+                )}
+
+                {/* for mobile */}
+                { isMobile && (
+                  <TextAreaEditor
+                    ref={taEditorRef}
+                    onPasteFiles={pasteFilesHandler}
+                    onDragEnter={dragEnterHandler}
+                    {...props}
+                  />
+                )}
+
+                <input {...getInputProps()} />
+              </div>
+            );
+          }}
+        </Dropzone>
+
+        { isUploadable
+          && (
+            <button
+              type="button"
+              className="btn btn-outline-secondary btn-block btn-open-dropzone"
+              onClick={addAttachmentHandler}
+            >
+              <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
+              Attach files
+              <span className="d-none d-sm-inline">
+              &nbsp;by dragging &amp; dropping,&nbsp;
+                <span className="btn-link">selecting them</span>,&nbsp;
+                or pasting from the clipboard.
+              </span>
+
+            </button>
+          )
+        }
+
+        { renderCheatsheetModal() }
+
+      </div>
+    </>
+  );
+});
+
+Editor.displayName = 'Editor';
+
+export default Editor;

+ 1 - 1
packages/app/src/components/PageList/PageListItemL.tsx

@@ -99,7 +99,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const lastUpdateDate = format(new Date(pageData.updatedAt), 'yyyy/MM/dd HH:mm:ss');
 
   useEffect(() => {
-    if (isIPageInfoForEntity(pageInfo) && pageInfo != null) {
+    if (isIPageInfoForEntity(pageInfo)) {
       // likerCount
       setLikerCount(pageInfo.likerIds?.length ?? 0);
       // bookmarkCount

+ 1 - 1
packages/app/src/components/PagePresentationModal.jsx

@@ -17,7 +17,7 @@ const PagePresentationModal = () => {
       isOpen={presentationData.isOpened}
       toggle={closePresentationModal}
       data-testid="page-presentation-modal"
-      className={`grw-presentation-modal ${styles['grw-presentation-modal']}`}
+      className={`grw-presentation-modal ${styles['grw-presentation-modal']} grw-body-only-modal-expanded`}
       unmountOnClose={false}
     >
       <ModalBody className="modal-body">

+ 0 - 4
packages/app/src/components/PagePresentationModal.module.scss

@@ -1,8 +1,4 @@
-@use '~/styles/mixins' as mi;
-
 .grw-presentation-modal :global {
-  @include mi.expand-modal-fullscreen(false, false);
-
   .modal-body {
     background: black;
 

+ 14 - 0
packages/app/src/components/PageTimeline.module.scss

@@ -0,0 +1,14 @@
+@use '../styles/bootstrap/variables' as var;
+
+.card-timeline {
+  :global {
+    border: 1px solid var.$gray-300;
+  }
+
+  &:global {
+    > .card-header {
+      background-color: var.$gray-300;
+    }
+  }
+}
+

+ 4 - 2
packages/app/src/components/PageTimeline.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useState, useCallback } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
@@ -10,6 +10,8 @@ import { useTimelineOptions } from '~/stores/renderer';
 import RevisionLoader from './Page/RevisionLoader';
 import PaginationWrapper from './PaginationWrapper';
 
+import styles from './PageTimeline.module.scss';
+
 export const PageTimeline = (): JSX.Element => {
   const [activePage, setActivePage] = useState(1);
   const [totalPageItems, setTotalPageItems] = useState(0);
@@ -51,7 +53,7 @@ export const PageTimeline = (): JSX.Element => {
       { pages.map((page) => {
         return (
           <div className="timeline-body" key={`key-${page._id}`}>
-            <div className="card card-timeline">
+            <div className={`card card-timeline ${styles['card-timeline']}`}>
               <div className="card-header"><a href={page.path}>{page.path}</a></div>
               <div className="card-body">
                 <RevisionLoader

+ 1 - 12
packages/app/src/components/PrivateLegacyPages.tsx

@@ -8,7 +8,6 @@ import {
 } from 'reactstrap';
 
 import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
-import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
@@ -190,21 +189,12 @@ ConvertByPathModal.displayName = 'ConvertByPathModal';
  * LegacyPage
  */
 
-type Props = {
-  appContainer: AppContainer,
-}
-
-const PrivateLegacyPages = (props: Props): JSX.Element => {
+const PrivateLegacyPages = (): JSX.Element => {
   const { t } = useTranslation();
   const { data: currentUser } = useCurrentUser();
 
   const isAdmin = currentUser?.admin;
 
-  const {
-    appContainer,
-  } = props;
-
-
   const [keyword, setKeyword] = useState<string>(initQ);
   const [offset, setOffset] = useState<number>(0);
   const [limit, setLimit] = useState<number>(INITIAL_PAGING_SIZE);
@@ -442,7 +432,6 @@ const PrivateLegacyPages = (props: Props): JSX.Element => {
     <>
       <SearchPageBase
         ref={searchPageBaseRef}
-        appContainer={appContainer}
         pages={data?.data}
         onSelectedPagesByCheckboxesChanged={selectedPagesByCheckboxesChangedHandler}
         forceHideMenuItems={[MenuItemType.BOOKMARK, MenuItemType.RENAME, MenuItemType.DUPLICATE, MenuItemType.REVERT, MenuItemType.PATH_RECOVERY]}

+ 22 - 0
packages/app/src/components/ReactMarkdownComponents/CodeBlock.module.scss

@@ -0,0 +1,22 @@
+@use '~/styles/variables' as var;
+@use '~/styles/bootstrap/init' as bs;
+
+.code-inline {
+  padding: 2px 4px;
+  font-family: var.$font-family-monospace-not-strictly;
+  border: 1px solid;
+  border-radius: bs.$border-radius;
+}
+
+.code-highlighted-title {
+  position: absolute;
+  top: 0;
+  right: 0.5em;
+  padding: 0 4px;
+  font-style: normal;
+  font-weight: bold;
+  color: bs.$gray-900;
+  background: bs.$gray-300;
+  border-radius: bs.$border-radius;
+  opacity: 0.6;
+}

+ 33 - 0
packages/app/src/components/ReactMarkdownComponents/CodeBlock.tsx

@@ -0,0 +1,33 @@
+import { CodeComponent } from 'react-markdown/lib/ast-to-react';
+import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
+import { oneLight } from 'react-syntax-highlighter/dist/cjs/styles/prism';
+
+import styles from './CodeBlock.module.scss';
+
+export const CodeBlock: CodeComponent = ({ inline, className, children }) => {
+  if (inline) {
+    return <code className={`code-inline ${styles['code-inline']} ${className ?? ''}`}>{children}</code>;
+  }
+
+  // TODO: set border according to the value of 'customize:highlightJsStyleBorder'
+
+  const match = /language-(\w+)(:?.+)?/.exec(className || '');
+  const lang = match && match[1] ? match[1] : '';
+  const name = match && match[2] ? match[2].slice(1) : null;
+
+  return (
+    <>
+      {name != null && (
+        <cite className={`code-highlighted-title ${styles['code-highlighted-title']}`}>{name}</cite>
+      )}
+      <SyntaxHighlighter
+        className="code-highlighted"
+        PreTag="div"
+        style={oneLight}
+        language={lang}
+      >
+        {String(children).replace(/\n$/, '')}
+      </SyntaxHighlighter>
+    </>
+  );
+};

+ 5 - 23
packages/app/src/components/SearchPage.tsx

@@ -2,13 +2,12 @@ import React, {
   useCallback, useEffect, useMemo, useRef, useState,
 } from 'react';
 
-import { parse as parseQuerystring } from 'querystring';
 
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
 
 
 import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
-import AppContainer from '~/client/services/AppContainer';
 import { IFormattedSearchResult } from '~/interfaces/search';
 import { useIsSearchServiceReachable } from '~/stores/context';
 import { ISearchConditions, ISearchConfigurations, useSWRxSearch } from '~/stores/search';
@@ -88,29 +87,13 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
 SearchResultListHead.displayName = 'SearchResultListHead';
 
 
-/**
- * SearchPage
- */
-
-const getParsedUrlQuery = () => {
-  const search = window.location.search || '?';
-  return parseQuerystring(search.slice(1)); // remove heading '?' and parse
-};
-
-type Props = {
-  appContainer: AppContainer,
-}
-
-export const SearchPage = (props: Props): JSX.Element => {
+export const SearchPage = (): JSX.Element => {
   const { t } = useTranslation();
-
-  const {
-    appContainer,
-  } = props;
+  const router = useRouter();
 
   // parse URL Query
-  const parsedQueries = getParsedUrlQuery().q;
-  const initQ = (Array.isArray(parsedQueries) ? parsedQueries.join(' ') : parsedQueries) ?? '';
+  const queries = router.query.q;
+  const initQ = (Array.isArray(queries) ? queries.join(' ') : queries) ?? '';
 
   const [keyword, setKeyword] = useState<string>(initQ);
   const [offset, setOffset] = useState<number>(0);
@@ -272,7 +255,6 @@ export const SearchPage = (props: Props): JSX.Element => {
   return (
     <SearchPageBase
       ref={searchPageBaseRef}
-      appContainer={appContainer}
       pages={data?.data}
       searchingKeyword={keyword}
       onSelectedPagesByCheckboxesChanged={selectedPagesByCheckboxesChangedHandler}

+ 10 - 8
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -3,6 +3,7 @@ import React, {
 } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
 import { DropdownItem } from 'reactstrap';
 
 import { exportAsMarkdown } from '~/client/services/page-operation';
@@ -19,13 +20,9 @@ import { useSearchResultOptions } from '~/stores/renderer';
 import { useFullTextSearchTermManager } from '~/stores/search';
 
 
-import AppContainer from '../../client/services/AppContainer';
 import { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
-import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
-import { SubNavButtons } from '../Navbar/SubNavButtons';
-import RevisionLoader from '../Page/RevisionLoader';
-import { PageComment } from '../PageComment';
-import PageContentFooter from '../PageContentFooter';
+import { GrowiSubNavigationProps } from '../Navbar/GrowiSubNavigation';
+import { SubNavButtonsProps } from '../Navbar/SubNavButtons';
 
 
 type AdditionalMenuItemsProps = AdditionalMenuItemsRendererProps & {
@@ -54,7 +51,6 @@ const SCROLL_OFFSET_TOP = 175; // approximate height of (navigation + subnavigat
 const MUTATION_OBSERVER_CONFIG = { childList: true, subtree: true };
 
 type Props ={
-  appContainer: AppContainer,
   pageWithMeta : IPageWithSearchMeta,
   highlightKeywords?: string[],
   showPageControlDropdown?: boolean,
@@ -81,6 +77,12 @@ const generateObserverCallback = (doScroll: ()=>void) => {
 };
 
 export const SearchResultContent: FC<Props> = (props: Props) => {
+  const GrowiSubNavigation = dynamic<GrowiSubNavigationProps>(() => import('../Navbar/GrowiSubNavigation').then(mod => mod.GrowiSubNavigation), { ssr: false });
+  const SubNavButtons = dynamic<SubNavButtonsProps>(() => import('../Navbar/SubNavButtons').then(mod => mod.SubNavButtons), { ssr: false });
+  const RevisionLoader = dynamic(() => import('../Page/RevisionLoader'), { ssr: false });
+  const PageComment = dynamic(() => import('../PageComment').then(mod => mod.PageComment), { ssr: false });
+  const PageContentFooter = dynamic(() => import('../PageContentFooter'), { ssr: false });
+
   const scrollElementRef = useRef(null);
 
   // for mutation
@@ -106,7 +108,6 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   // *******************************  end  *******************************
 
   const {
-    appContainer,
     pageWithMeta,
     highlightKeywords,
     showPageControlDropdown,
@@ -175,6 +176,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
       ? page.revision
       : page.revision._id;
 
+
     return (
       <div className="d-flex flex-column align-items-end justify-content-center py-md-2">
         <SubNavButtons

+ 0 - 1
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -125,7 +125,6 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
     advanceFts();
   };
 
-
   return (
     <ul data-testid="search-result-list" className="page-list-ul list-group list-group-flush">
       { (injectedPages ?? pages).map((page, i) => {

+ 2 - 6
packages/app/src/components/SearchPage2/SearchPageBase.tsx

@@ -3,9 +3,9 @@ import React, {
 } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
 
 import { ISelectableAll } from '~/client/interfaces/selectable-all';
-import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess } from '~/client/util/apiNotification';
 import { IFormattedSearchResult, IPageWithSearchMeta } from '~/interfaces/search';
 import { OnDeletedFunction } from '~/interfaces/ui';
@@ -14,7 +14,6 @@ import { usePageDeleteModal } from '~/stores/modal';
 import { usePageTreeTermManager } from '~/stores/page-listing';
 
 import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
-import { SearchResultContent } from '../SearchPage/SearchResultContent';
 import { SearchResultList } from '../SearchPage/SearchResultList';
 
 
@@ -28,8 +27,6 @@ export interface IReturnSelectedPageIds {
 
 
 type Props = {
-  appContainer: AppContainer,
-
   pages?: IPageWithSearchMeta[],
   searchingKeyword?: string,
 
@@ -43,8 +40,8 @@ type Props = {
 }
 
 const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturnSelectedPageIds, Props> = (props:Props, ref) => {
+  const SearchResultContent = dynamic(import('../SearchPage/SearchResultContent').then(mod => mod.SearchResultContent), { ssr: false });
   const {
-    appContainer,
     pages,
     searchingKeyword,
     forceHideMenuItems,
@@ -203,7 +200,6 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
         <div className="mw-0 flex-grow-1 flex-basis-0 d-none d-lg-block search-result-content">
           { selectedPageWithMeta != null && (
             <SearchResultContent
-              appContainer={appContainer}
               pageWithMeta={selectedPageWithMeta}
               highlightKeywords={highlightKeywords}
               showPageControlDropdown={!isGuestUser}

+ 2 - 2
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -21,7 +21,7 @@ import { IPageForPageDuplicateModal } from '~/stores/modal';
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
-
+import { shouldRecoverPagePaths } from '~/utils/page-operation';
 
 import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
 import CountBadge from '../../Common/CountBadge';
@@ -411,7 +411,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
   // Rename process
   // Icon that draw attention from users for some actions
-  const shouldShowAttentionIcon = !!page.processData?.Rename?.isProcessable;
+  const shouldShowAttentionIcon = page.processData != null ? shouldRecoverPagePaths(page.processData) : false;
 
   return (
     <div

+ 2 - 0
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -17,8 +17,10 @@ import FormattedDistanceDate from '../FormattedDistanceDate';
 
 import InfiniteScroll from './InfiniteScroll';
 
+import TagLabelsStyles from '../Page/TagLabels.module.scss';
 import styles from './RecentChanges.module.scss';
 
+
 const logger = loggerFactory('growi:History');
 
 type PageItemProps = {

+ 2 - 9
packages/app/src/components/Skelton.tsx

@@ -1,24 +1,17 @@
 import React from 'react';
 
 type SkeltonProps = {
-  width?: number,
-  height?: number,
   additionalClass?: string,
   roundedPill?: boolean,
 }
 
 export const Skelton = (props: SkeltonProps): JSX.Element => {
   const {
-    width, height, additionalClass, roundedPill,
+    additionalClass, roundedPill,
   } = props;
 
-  const skeltonStyle = {
-    width,
-    height,
-  };
-
   return (
-    <div style={skeltonStyle} className={`${additionalClass}`}>
+    <div className={`${additionalClass}`}>
       <div className={`grw-skelton h-100 w-100 ${roundedPill ? 'rounded-pill' : ''}`}></div>
     </div>
   );

+ 11 - 0
packages/app/src/components/TableOfContents.module.scss

@@ -1,3 +1,4 @@
+@use '~/styles/bootstrap/init' as bs;
 
 .revision-toc :global {
   // to get on the Attachment row
@@ -26,3 +27,13 @@
     }
   }
 }
+
+.revision-toc {
+  @media print {
+    float: none;
+    max-width: 100%;
+    margin-bottom: 20px;
+    font-size: 0.9em;
+    border: solid 1px bs.$gray-400;
+  }
+}

+ 19 - 13
packages/app/src/components/TableOfContents.tsx

@@ -11,6 +11,10 @@ import loggerFactory from '~/utils/logger';
 
 import { StickyStretchableScroller } from './StickyStretchableScroller';
 
+
+import styles from './TableOfContents.module.scss';
+
+
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:TableOfContents');
 
@@ -53,20 +57,22 @@ const TableOfContents = (): JSX.Element => {
   }, [tocHtml]);
 
   return (
-    <StickyStretchableScroller
-      stickyElemSelector=".grw-side-contents-sticky-container"
-      calcViewHeight={calcViewHeight}
-    >
-      <div
-        id="revision-toc-content"
-        className="revision-toc-content mb-3"
+    <div id="revision-toc" className={`revision-toc ${styles['revision-toc']}`}>
+      <StickyStretchableScroller
+        stickyElemSelector=".grw-side-contents-sticky-container"
+        calcViewHeight={calcViewHeight}
       >
-        {/* parse blank to show toc (https://github.com/weseek/growi/pull/6277) */}
-        <ReactMarkdown {...rendererOptions}>
-          {''}
-        </ReactMarkdown>
-      </div>
-    </StickyStretchableScroller>
+        <div
+          id="revision-toc-content"
+          className="revision-toc-content mb-3"
+        >
+          {/* parse blank to show toc (https://github.com/weseek/growi/pull/6277) */}
+          <ReactMarkdown {...rendererOptions}>
+            {''}
+          </ReactMarkdown>
+        </div>
+      </StickyStretchableScroller>
+    </div>
   );
 
 };

+ 17 - 0
packages/app/src/interfaces/editor-methods.ts

@@ -0,0 +1,17 @@
+export interface IEditorMethods {
+  forceToFocus: () => void,
+  setValue: (newValue: string) => void,
+  setGfmMode: (bool: boolean) => void,
+  setCaretLine: (line: number) => void,
+  setScrollTopByLine: (line: number) => void,
+  getStrFromBol(): void,
+  getStrToEol: () => void,
+  getStrFromBolToSelectedUpperPos: () => void,
+  replaceBolToCurrentPos: (text: string) => void,
+  replaceLine: (text: string) => void,
+  insertText: (text: string) => void,
+  insertLinebreak: () => void,
+  dispatchSave: () => void,
+  dispatchPasteFiles: (event: Event) => void,
+  getNavbarItems: () => JSX.Element[],
+}

+ 15 - 4
packages/app/src/interfaces/page-operation.ts

@@ -6,10 +6,21 @@ export const PageActionType = {
   Revert: 'Revert',
   NormalizeParent: 'NormalizeParent',
 } as const;
-export type PageActionType = typeof PageActionType[keyof typeof PageActionType]
-export type IPageOperationProcessData = Partial<{
-  [key in PageActionType]: {isProcessable: boolean}
-}>
+export type PageActionType = typeof PageActionType[keyof typeof PageActionType];
+
+export const PageActionStage = {
+  Main: 'Main',
+  Sub: 'Sub',
+} as const;
+export type PageActionStage = typeof PageActionStage[keyof typeof PageActionStage];
+
+export type IPageOperationProcessData = {
+  [key in PageActionType]?: {
+    [PageActionStage.Main]?: { isProcessable: boolean },
+    [PageActionStage.Sub]?: { isProcessable: boolean },
+  }
+}
+
 export type IPageOperationProcessInfo = {
   [pageId: string]: IPageOperationProcessData,
 }

+ 4 - 0
packages/app/src/interfaces/services/renderer.ts

@@ -1,3 +1,5 @@
+import { HastNode } from 'hast-util-select';
+
 import { XssOptionConfig } from '~/services/xss/xssOption';
 
 // export type GrowiHydratedEnv = {
@@ -18,3 +20,5 @@ export type RendererConfig = {
   plantumlUri: string | null,
   blockdiagUri: string | null,
 } & XssOptionConfig;
+
+export type RehypePlugin = (option: any) => (node: HastNode) => void

+ 5 - 0
packages/app/src/interfaces/theme.ts

@@ -16,3 +16,8 @@ export const GrowiThemes = {
   WOOD: 'wood',
 } as const;
 export type GrowiThemes = typeof GrowiThemes[keyof typeof GrowiThemes];
+
+export const PrismThemes = {
+  OneLight: 'one-light',
+} as const;
+export type PrismThemes = typeof PrismThemes[keyof typeof PrismThemes];

+ 9 - 12
packages/app/src/pages/[[...path]].page.tsx

@@ -7,7 +7,7 @@ import {
   IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision, isClient, isIPageInfoForEntity, isServer, IUser, IUserHasId, pagePathUtils, pathUtils,
 } from '@growi/core';
 import ExtensibleCustomError from 'extensible-custom-error';
-import mongoose from 'mongoose';
+import { model as mongooseModel } from 'mongoose';
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
@@ -20,7 +20,7 @@ import superjson from 'superjson';
 import { PageAlerts } from '~/components/PageAlert/PageAlerts';
 import { PageComment } from '~/components/PageComment';
 // import { useTranslation } from '~/i18n';
-import CommentEditorLazyRenderer from '~/components/PageComment/CommentEditorLazyRenderer';
+// import CommentEditorLazyRenderer from '~/components/PageComment/CommentEditorLazyRenderer';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 // import { renderScriptTagByName, renderHighlightJsStyleTag } from '~/service/cdn-resources-loader';
 // import { useIndentSize } from '~/stores/editor';
@@ -32,8 +32,7 @@ import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import { PageModel, PageDocument } from '~/server/models/page';
 import { PageRedirectModel } from '~/server/models/page-redirect';
-import UserUISettings from '~/server/models/user-ui-settings';
-import Xss from '~/services/xss';
+import { UserUISettingsModel } from '~/server/models/user-ui-settings';
 import { useSWRxCurrentPage, useSWRxIsGrantNormalized, useSWRxPageInfo } from '~/stores/page';
 import {
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth, useSelectedGrant,
@@ -44,16 +43,12 @@ import loggerFactory from '~/utils/logger';
 
 // import GrowiSubNavigation from '../client/js/components/Navbar/GrowiSubNavigation';
 // import GrowiSubNavigationSwitcher from '../client/js/components/Navbar/GrowiSubNavigationSwitcher';
-import ForbiddenPage from '../components/ForbiddenPage';
+import { DescendantsPageListModal } from '../components/DescendantsPageListModal';
 import { BasicLayout } from '../components/Layout/BasicLayout';
 import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
-import { NotCreatablePage } from '../components/NotCreatablePage';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
-
 // import { serializeUserSecurely } from '../server/models/serializers/user-serializer';
 // import PageStatusAlert from '../client/js/components/PageStatusAlert';
-
-
 import {
   useCurrentUser, useCurrentPagePath,
   useIsLatestRevision,
@@ -65,7 +60,6 @@ import {
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useIsSlackConfigured, useIsBlinkedHeaderAtBoot, useRendererConfig, useEditingMarkdown,
 } from '../stores/context';
-import { useXss } from '../stores/xss';
 
 import {
   CommonProps, getNextI18NextConfig, getServerSideCommonProps, useCustomTitle,
@@ -169,6 +163,8 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   // const { t } = useTranslation();
   const router = useRouter();
 
+  const NotCreatablePage = dynamic(() => import('../components/NotCreatablePage').then(mod => mod.NotCreatablePage), { ssr: false });
+  const ForbiddenPage = dynamic(() => import('../components/ForbiddenPage'), { ssr: false });
   const UnsavedAlertDialog = dynamic(() => import('./UnsavedAlertDialog'), { ssr: false });
   const GrowiSubNavigationSwitcher = dynamic(() => import('../components/Navbar/GrowiSubNavigationSwitcher'), { ssr: false });
 
@@ -180,7 +176,6 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   }
 
   // commons
-  useXss(new Xss());
   // useEditorConfig(props.editorConfig);
   useCsrfToken(props.csrfToken);
 
@@ -331,6 +326,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
         </footer>
 
         <UnsavedAlertDialog />
+        <DescendantsPageListModal />
         {shouldRenderPutbackPageModal && <PutbackPageModal />}
 
       </BasicLayout>
@@ -360,7 +356,7 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
   const { revisionId } = req.query;
 
   const Page = crowi.model('Page') as PageModel;
-  const PageRedirect = mongoose.model('PageRedirect') as PageRedirectModel;
+  const PageRedirect = mongooseModel('PageRedirect') as PageRedirectModel;
   const { pageService } = crowi;
 
   let currentPathname = props.currentPathname;
@@ -404,6 +400,7 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
 async function injectUserUISettings(context: GetServerSidePropsContext, props: Props): Promise<void> {
   const req = context.req as CrowiRequest<IUserHasId & any>;
   const { user } = req;
+  const UserUISettings = mongooseModel('UserUISettings') as UserUISettingsModel;
 
   const userUISettings = user == null ? null : await UserUISettings.findOne({ user: user._id }).exec();
   if (userUISettings != null) {

+ 5 - 21
packages/app/src/pages/_document.page.tsx

@@ -1,45 +1,29 @@
 import React from 'react';
 
-import fs from 'fs';
-
 import Document, {
   DocumentContext, DocumentInitialProps,
   Html, Head, Main, NextScript,
 } from 'next/document';
 
-// import { renderScriptTagsByGroup, renderStyleTagsByGroup } from '~/service/cdn-resources-loader';
-import { resolveFromRoot } from '~/utils/project-dir-utils';
 
-interface GrowiDocumentProps {
-  bootJsPath: string;
-}
-declare type GrowiDocumentInitialProps = DocumentInitialProps & GrowiDocumentProps;
+// type GrowiDocumentProps = {};
+// declare type GrowiDocumentInitialProps = GrowiDocumentProps & DocumentInitialProps;
+declare type GrowiDocumentInitialProps = DocumentInitialProps;
 
-async function importCustomManifest(): Promise<any> {
-  const customManifestStr: string = await fs.readFileSync(resolveFromRoot('.next/custom-manifest.json'), 'utf-8');
-  return JSON.parse(customManifestStr);
-}
 
-class GrowiDocument extends Document<GrowiDocumentProps> {
+class GrowiDocument extends Document {
 
   static override async getInitialProps(ctx: DocumentContext): Promise<GrowiDocumentInitialProps> {
     const initialProps: DocumentInitialProps = await Document.getInitialProps(ctx);
 
-    const customManifest: any = await importCustomManifest();
-    const bootJsPath = customManifest['boot.js'];
-
-    return { ...initialProps, bootJsPath };
+    return initialProps;
   }
 
   override render(): JSX.Element {
 
-    const { bootJsPath } = this.props;
-
     return (
       <Html>
         <Head>
-          {/* eslint-disable-next-line @next/next/no-sync-scripts */}
-          <script src={bootJsPath}></script>
           {/*
           {renderScriptTagsByGroup('basis')}
           {renderStyleTagsByGroup('basis')}

+ 15 - 15
packages/app/src/pages/_search.page.tsx

@@ -21,7 +21,8 @@ import {
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed,
   useCurrentSidebarContents, useCurrentProductNavWidth,
 } from '~/stores/ui';
-import { useXss } from '~/stores/xss';
+
+import { SearchPage } from '../components/SearchPage';
 
 import {
   CommonProps, getNextI18NextConfig, getServerSideCommonProps, useCustomTitle,
@@ -44,11 +45,10 @@ type Props = CommonProps & {
 
 };
 
-const SearchPage: NextPage<Props> = (props: Props) => {
+const SearchResultPage: NextPage<Props> = (props: Props) => {
   const { userUISettings } = props;
 
   // commons
-  useXss(new Xss());
   useCsrfToken(props.csrfToken);
 
   useCurrentUser(props.currentUser ?? null);
@@ -87,20 +87,20 @@ const SearchPage: NextPage<Props> = (props: Props) => {
         {renderScriptTagByName('highlight-addons')}
         */}
       </Head>
-      <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
-
-        <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
-        <div id="main" className="main search-page mt-0">
+      <div className="on-search">
+        <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
 
-          <div id="search-page">
-            Search Result Page
-            {/* render SearchPage component here */}
-          </div>
+          <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
+          <div id="main" className="main search-page mt-0">
 
-        </div>
-        <PutbackPageModal />
-      </BasicLayout>
+            <div id="search-page">
+              <SearchPage />
+            </div>
 
+          </div>
+          <PutbackPageModal />
+        </BasicLayout>
+      </div>
     </>
   );
 };
@@ -184,4 +184,4 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   };
 };
 
-export default SearchPage;
+export default SearchResultPage;

+ 4 - 1
packages/app/src/server/models/obsolete-page.js

@@ -683,6 +683,7 @@ export const getPageSchema = (crowi) => {
     const Revision = crowi.model('Revision');
     const format = options.format || 'markdown';
     const grantUserGroupId = options.grantUserGroupId || null;
+    const expandContentWidth = crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
 
     // sanitize path
     path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
@@ -704,7 +705,9 @@ export const getPageSchema = (crowi) => {
     page.creator = user;
     page.lastUpdateUser = user;
     page.status = STATUS_PUBLISHED;
-
+    if (expandContentWidth != null) {
+      page.expandContentWidth = expandContentWidth;
+    }
     await validateAppliedScope(user, grant, grantUserGroupId);
     page.applyScope(user, grant, grantUserGroupId);
 

+ 1 - 16
packages/app/src/server/models/page-operation.ts

@@ -3,6 +3,7 @@ import mongoose, {
   Schema, Model, Document, QueryOptions, FilterQuery,
 } from 'mongoose';
 
+import { PageActionType, PageActionStage } from '~/interfaces/page-operation';
 import {
   IPageForResuming, IUserForResuming, IOptionsForResuming,
 } from '~/server/models/interfaces/page-operation';
@@ -19,22 +20,6 @@ const logger = loggerFactory('growi:models:page-operation');
 type IObjectId = mongoose.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
-export const PageActionType = {
-  Rename: 'Rename',
-  Duplicate: 'Duplicate',
-  Delete: 'Delete',
-  DeleteCompletely: 'DeleteCompletely',
-  Revert: 'Revert',
-  NormalizeParent: 'NormalizeParent',
-} as const;
-export type PageActionType = typeof PageActionType[keyof typeof PageActionType];
-
-export const PageActionStage = {
-  Main: 'Main',
-  Sub: 'Sub',
-} as const;
-export type PageActionStage = typeof PageActionStage[keyof typeof PageActionStage];
-
 /*
  * Main Schema
  */

+ 1 - 0
packages/app/src/server/models/page.ts

@@ -104,6 +104,7 @@ const schema = new Schema<PageDocument, PageModel>({
   pageIdOnHackmd: { type: String },
   revisionHackmdSynced: { type: ObjectId, ref: 'Revision' }, // the revision that is synced to HackMD
   hasDraftOnHackmd: { type: Boolean }, // set true if revision and revisionHackmdSynced are same but HackMD document has modified
+  expandContentWidth: { type: Boolean },
   updatedAt: { type: Date, default: Date.now }, // Do not use timetamps for updatedAt because it breaks 'updateMetadata: false' option
   deleteUser: { type: ObjectId, ref: 'User' },
   deletedAt: { type: Date },

+ 27 - 2
packages/app/src/server/routes/apiv3/page.js

@@ -17,7 +17,6 @@ const router = express.Router();
 const { convertToNewAffiliationPath, isTopPage } = pagePathUtils;
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
-
 /**
  * @swagger
  *  tags:
@@ -164,8 +163,9 @@ module.exports = (crowi) => {
   const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
+  const configManager = crowi.configManager;
+
   const globalNotificationService = crowi.getGlobalNotificationService();
-  const socketIoService = crowi.socketIoService;
   const { Page, GlobalNotificationSetting, Bookmark } = crowi.models;
   const { pageService, exportService } = crowi;
 
@@ -219,6 +219,9 @@ module.exports = (crowi) => {
     subscribeStatus: [
       query('pageId').isString(),
     ],
+    contentWidth: [
+      body('expandContentWidth').isBoolean(),
+    ],
   };
 
   /**
@@ -825,5 +828,27 @@ module.exports = (crowi) => {
     }
   });
 
+
+  router.put('/:pageId/content-width', accessTokenParser, loginRequiredStrictly,
+    validator.contentWidth, apiV3FormValidator, async(req, res) => {
+      const { pageId } = req.params;
+      const { expandContentWidth } = req.body;
+
+      const isContainerFluidBySystem = configManager.getConfig('crowi', 'customize:isContainerFluid');
+
+      try {
+        const updateQuery = expandContentWidth === isContainerFluidBySystem
+          ? { $unset: { expandContentWidth } } // remove if the specified value is the same to the system's one
+          : { $set: { expandContentWidth } };
+
+        const page = await Page.updateOne({ _id: pageId }, updateQuery);
+        return res.apiv3({ page });
+      }
+      catch (err) {
+        logger.error('update-content-width-failed', err);
+        return res.apiv3Err(err, 500);
+      }
+    });
+
   return router;
 };

+ 7 - 1
packages/app/src/server/routes/apiv3/pages.js

@@ -574,8 +574,14 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(msg, code), 403);
     }
 
+    const pageOp = await crowi.pageOperationService.getRenameSubOperationByPageId(page._id);
+    if (pageOp == null) {
+      const msg = 'PageOperation document for Rename Sub operation not found.';
+      const code = 'document_not_found';
+      return res.apiv3Err(new ErrorV3(msg, code), 404);
+    }
+
     try {
-      const pageOp = await crowi.pageOperationService.getRenameSubOperationByPageId(page._id);
       await crowi.pageService.resumeRenameSubOperation(page, pageOp);
     }
     catch (err) {

+ 15 - 5
packages/app/src/server/service/page-operation.ts

@@ -1,7 +1,9 @@
 import { pagePathUtils } from '@growi/core';
 
-import { IPageOperationProcessInfo, IPageOperationProcessData } from '~/interfaces/page-operation';
-import PageOperation, { PageActionType, PageActionStage, PageOperationDocument } from '~/server/models/page-operation';
+import {
+  IPageOperationProcessInfo, IPageOperationProcessData, PageActionType, PageActionStage,
+} from '~/interfaces/page-operation';
+import PageOperation, { PageOperationDocument } from '~/server/models/page-operation';
 import loggerFactory from '~/utils/logger';
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
@@ -26,9 +28,10 @@ class PageOperationService {
   }
 
   async init(): Promise<void> {
-    // cleanup PageOperation documents except ones with actionType: Rename
+    // cleanup PageOperation documents except ones with { actionType: Rename, actionStage: Sub }
     const types = [Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent];
     await PageOperation.deleteByActionTypes(types);
+    await PageOperation.deleteMany({ actionType: PageActionType.Rename, actionStage: PageActionStage.Main });
   }
 
   /**
@@ -137,12 +140,19 @@ class PageOperationService {
       const isProcessable = pageOp.isProcessable();
 
       // processData for processInfo
-      const processData: IPageOperationProcessData = { [actionType]: { isProcessable } };
+      const mainProcessableInfo = pageOp.actionStage === PageActionStage.Main ? { isProcessable } : undefined;
+      const subProcessableInfo = pageOp.actionStage === PageActionStage.Sub ? { isProcessable } : undefined;
+      const processData: IPageOperationProcessData = {
+        [actionType]: {
+          [PageActionStage.Main]: mainProcessableInfo,
+          [PageActionStage.Sub]: subProcessableInfo,
+        },
+      };
 
       // Merge processData if other processData exist
       if (processInfo[pageId] != null) {
         const otherProcessData = processInfo[pageId];
-        processInfo[pageId] = { ...otherProcessData, ...processData };
+        processInfo[pageId] = Object.assign(otherProcessData, processData);
         return;
       }
       // add new process data to processInfo

+ 95 - 10
packages/app/src/server/service/page.ts

@@ -15,7 +15,9 @@ import {
 import {
   PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
 } from '~/interfaces/page-delete-config';
-import { IPageOperationProcessInfo, IPageOperationProcessData } from '~/interfaces/page-operation';
+import {
+  IPageOperationProcessInfo, IPageOperationProcessData, PageActionStage, PageActionType,
+} from '~/interfaces/page-operation';
 import { IUserHasId } from '~/interfaces/user';
 import { PageMigrationErrorData, SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import {
@@ -27,7 +29,7 @@ import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { PathAlreadyExistsError } from '../models/errors';
-import PageOperation, { PageActionStage, PageActionType, PageOperationDocument } from '../models/page-operation';
+import PageOperation, { PageOperationDocument } from '../models/page-operation';
 import { PageRedirectModel } from '../models/page-redirect';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
 import Subscription from '../models/subscription';
@@ -401,7 +403,19 @@ class PageService {
       logger.error('Failed to create PageOperation document.', err);
       throw err;
     }
-    const renamedPage = await this.renameMainOperation(page, newPagePath, user, options, pageOp._id);
+
+    let renamedPage: PageDocument | null = null;
+    try {
+      renamedPage = await this.renameMainOperation(page, newPagePath, user, options, pageOp._id);
+    }
+    catch (err) {
+      logger.error('Error occurred while running renameMainOperation', err);
+
+      // cleanup
+      await PageOperation.deleteOne({ _id: pageOp._id });
+
+      throw err;
+    }
 
     return renamedPage;
   }
@@ -996,7 +1010,20 @@ class PageService {
         logger.error('Failed to create PageOperation document.', err);
         throw err;
       }
-      this.duplicateRecursivelyMainOperation(page, newPagePath, user, pageOp._id);
+
+      (async() => {
+        try {
+          await this.duplicateRecursivelyMainOperation(page, newPagePath, user, pageOp._id);
+        }
+        catch (err) {
+          logger.error('Error occurred while running duplicateRecursivelyMainOperation.', err);
+
+          // cleanup
+          await PageOperation.deleteOne({ _id: pageOp._id });
+
+          throw err;
+        }
+      })();
     }
 
     const result = serializePageSecurely(duplicatedTarget);
@@ -1387,7 +1414,19 @@ class PageService {
       /*
        * Resumable Operation
        */
-      this.deleteRecursivelyMainOperation(page, user, pageOp._id);
+      (async() => {
+        try {
+          await this.deleteRecursivelyMainOperation(page, user, pageOp._id);
+        }
+        catch (err) {
+          logger.error('Error occurred while running deleteRecursivelyMainOperation.', err);
+
+          // cleanup
+          await PageOperation.deleteOne({ _id: pageOp._id });
+
+          throw err;
+        }
+      })();
     }
 
     return deletedPage;
@@ -1703,7 +1742,19 @@ class PageService {
       /*
        * Main Operation
        */
-      this.deleteCompletelyRecursivelyMainOperation(page, user, options, pageOp._id);
+      (async() => {
+        try {
+          await this.deleteCompletelyRecursivelyMainOperation(page, user, options, pageOp._id);
+        }
+        catch (err) {
+          logger.error('Error occurred while running deleteCompletelyRecursivelyMainOperation.', err);
+
+          // cleanup
+          await PageOperation.deleteOne({ _id: pageOp._id });
+
+          throw err;
+        }
+      })();
     }
 
     return;
@@ -1911,7 +1962,19 @@ class PageService {
       /*
        * Resumable Operation
        */
-      this.revertRecursivelyMainOperation(page, user, options, pageOp._id);
+      (async() => {
+        try {
+          await this.revertRecursivelyMainOperation(page, user, options, pageOp._id);
+        }
+        catch (err) {
+          logger.error('Error occurred while running revertRecursivelyMainOperation.', err);
+
+          // cleanup
+          await PageOperation.deleteOne({ _id: pageOp._id });
+
+          throw err;
+        }
+      })();
     }
 
     return updatedPage;
@@ -2110,6 +2173,7 @@ class PageService {
 
     const likers = page.liker.slice(0, 15) as Ref<IUserHasId>[];
     const seenUsers = page.seenUsers.slice(0, 15) as Ref<IUserHasId>[];
+    const expandContentWidth = page.expandContentWidth ?? this.crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
 
     return {
       isV5Compatible: isTopPage(page.path) || page.parent != null,
@@ -2123,6 +2187,7 @@ class PageService {
       isAbleToDeleteCompletely: false,
       isRevertible: isTrashPage(page.path),
       contentAge: page.getContentAge(),
+      expandContentWidth,
     };
 
   }
@@ -2296,7 +2361,19 @@ class PageService {
       throw err;
     }
 
-    this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
+    (async() => {
+      try {
+        await this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
+      }
+      catch (err) {
+        logger.error('Error occurred while running normalizeParentRecursivelyMainOperation.', err);
+
+        // cleanup
+        await PageOperation.deleteOne({ _id: pageOp._id });
+
+        throw err;
+      }
+    })();
   }
 
   async normalizeParentByPageIdsRecursively(pageIds: ObjectIdLike[], user): Promise<void> {
@@ -2481,7 +2558,11 @@ class PageService {
       }
       catch (err) {
         errorPagePaths.push(page.path);
-        logger.err('Failed to run normalizeParentRecursivelyMainOperation.', err);
+        logger.error('Failed to run normalizeParentRecursivelyMainOperation.', err);
+
+        // cleanup
+        await PageOperation.deleteOne({ _id: pageOp._id });
+
         throw err;
       }
     }
@@ -3276,6 +3357,8 @@ class PageService {
   async create(path: string, body: string, user, options: PageCreateOptions = {}): Promise<PageDocument> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
+    const expandContentWidth = this.crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
+
     // Switch method
     const isV5Compatible = this.crowi.configManager.getConfig('crowi', 'app:isV5Compatible');
     if (!isV5Compatible) {
@@ -3322,7 +3405,9 @@ class PageService {
       const parent = await this.getParentAndFillAncestorsByUser(user, path);
       page.parent = parent._id;
     }
-
+    if (expandContentWidth != null) {
+      page.expandContentWidth = expandContentWidth;
+    }
     // Save
     let savedPage = await page.save();
 

+ 3 - 0
packages/app/src/server/util/rate-limiter.ts

@@ -20,8 +20,10 @@ const generateApiRateLimitConfigFromEndpoint = (envVar: NodeJS.ProcessEnv, targe
     }
     const methodKey = `API_RATE_LIMIT_${target}_METHODS`;
     const maxRequestsKey = `API_RATE_LIMIT_${target}_MAX_REQUESTS`;
+    const usersPerIpProspectionKey = `API_RATE_LIMIT_${target}_USERS_PER_IP`;
     const method = envVar[methodKey] ?? 'ALL';
     const maxRequests = Number(envVar[maxRequestsKey]);
+    const usersPerIpProspection = Number(envVar[usersPerIpProspectionKey]);
 
     if (endpoint == null || maxRequests == null) {
       return;
@@ -30,6 +32,7 @@ const generateApiRateLimitConfigFromEndpoint = (envVar: NodeJS.ProcessEnv, targe
     const config = {
       method,
       maxRequests,
+      usersPerIpProspection,
     };
 
     apiRateLimitConfig[endpoint] = config;

+ 9 - 1
packages/app/src/server/views/layout/layout.html

@@ -61,9 +61,17 @@
 {% block html_body %}
 {% set additionalBodyClasses = []; %}
 {% block html_additional_body_classes %}{% endblock %}
-{% if getConfig('crowi', 'customize:isContainerFluid') %}
+
+{% if page.expandContentWidth !== undefined %}
+  {% set isContainerFluid = page.expandContentWidth; %}
+{% else %}
+  {% set isContainerFluid = getConfig('crowi', 'customize:isContainerFluid'); %}
+{% endif %}
+
+{% if isContainerFluid  %}
   {% set additionalBodyClasses = additionalBodyClasses|push('growi-layout-fluid') %}
 {% endif %}
+
 <body
   class="{% block html_base_css %}{% endblock %} growi {{ additionalBodyClasses|join(' ') }}"
   data-plugin-enabled="{{ getConfig('crowi', 'plugin:isEnabledPlugins') }}"

+ 3 - 1
packages/app/src/services/renderer/markdown-it/link-by-relative-path.ts

@@ -12,7 +12,9 @@ export default class LinkerByRelativePathConfigurer {
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  configure(md, pagePath: string): void {
+  configure(md): void {
+    const pagePath = this.pagePath;
+
     // Remember old renderer, if overridden, or proxy to default renderer
     const defaultRender = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
       return self.renderToken(tokens, idx, options);

+ 39 - 0
packages/app/src/services/renderer/rehype-plugins/add-class.ts

@@ -0,0 +1,39 @@
+// See: https://github.com/martypdx/rehype-add-classes for the original implementation.
+// Re-implemeted in TypeScript.
+
+import { selectAll, HastNode, Element } from 'hast-util-select';
+
+import { RehypePlugin } from '~/interfaces/services/renderer';
+
+export type SelectorName = string; // e.g. 'h1'
+export type ClassName = string; // e.g. 'header'
+export type Additions = Record<SelectorName, ClassName>;
+export type AdditionsEntry = [SelectorName, ClassName];
+
+const generateWriter = (className: string) => (element: Element) => {
+  const { properties } = element;
+
+  if (properties == null) {
+    return;
+  }
+
+  if (properties.className == null) {
+    properties.className = className;
+    return;
+  }
+
+  properties.className += ` ${className}`;
+};
+
+const adder = (entry: AdditionsEntry) => {
+  const [selectorName, className] = entry;
+  const writer = generateWriter(className);
+
+  return (node: HastNode) => selectAll(selectorName, node).forEach(writer);
+};
+
+export const addClass: RehypePlugin = (additions) => {
+  const adders = Object.entries(additions).map(adder);
+
+  return node => adders.forEach(a => a(node));
+};

+ 10 - 0
packages/app/src/services/renderer/renderer.tsx

@@ -1,4 +1,5 @@
 import { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown';
+import katex from 'rehype-katex';
 import raw from 'rehype-raw';
 import sanitize, { defaultSchema } from 'rehype-sanitize';
 import slug from 'rehype-slug';
@@ -6,10 +7,13 @@ import toc, { HtmlElementNode } from 'rehype-toc';
 import breaks from 'remark-breaks';
 import emoji from 'remark-emoji';
 import gfm from 'remark-gfm';
+import math from 'remark-math';
 
+import { CodeBlock } from '~/components/ReactMarkdownComponents/CodeBlock';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 import { RendererConfig } from '~/interfaces/services/renderer';
+import { addClass } from '~/services/renderer/rehype-plugins/add-class';
 import loggerFactory from '~/utils/logger';
 
 // import CsvToTable from './PreProcessor/CsvToTable';
@@ -225,9 +229,13 @@ const generateCommonOptions: ReactMarkdownOptionsGenerator = (config: RendererCo
           '*': ['className', 'class'],
         },
       }],
+      [addClass, {
+        table: 'table table-bordered',
+      }],
     ],
     components: {
       a: NextLink,
+      code: CodeBlock,
     },
   };
 };
@@ -244,6 +252,7 @@ export const generateViewOptions = (
   // add remark plugins
   if (remarkPlugins != null) {
     remarkPlugins.push(emoji);
+    remarkPlugins.push(math);
     if (config.isEnabledLinebreaks) {
       remarkPlugins.push(breaks);
     }
@@ -251,6 +260,7 @@ export const generateViewOptions = (
 
   // store toc node
   if (rehypePlugins != null) {
+    rehypePlugins.push(katex);
     rehypePlugins.push([toc, {
       nav: false,
       headings: ['h1', 'h2', 'h3'],

+ 8 - 1
packages/app/src/stores/page.tsx

@@ -81,11 +81,18 @@ export const useSWRxPageInfo = (
   // assign null if shareLinkId is undefined in order to identify SWR key only by pageId
   const fixedShareLinkId = shareLinkId ?? null;
 
-  return useSWRImmutable<IPageInfo | IPageInfoForOperation, Error>(
+  const swrResult = useSWRImmutable<IPageInfo | IPageInfoForOperation, Error>(
     pageId != null ? ['/page/info', pageId, fixedShareLinkId] : null,
     (endpoint, pageId, shareLinkId) => apiv3Get(endpoint, { pageId, shareLinkId }).then(response => response.data),
     { fallbackData: initialData },
   );
+
+  // use mutate because fallbackData does not work
+  if (initialData != null) {
+    swrResult.mutate(initialData);
+  }
+
+  return swrResult;
 };
 
 export const useSWRxPageRevisions = (

+ 0 - 3
packages/app/src/styles/_drawio.scss

@@ -1,3 +0,0 @@
-.drawio-modal {
-  @include expand-modal-fullscreen(false, false);
-}

+ 0 - 8
packages/app/src/styles/_layout.scss

@@ -116,14 +116,6 @@ body.not-found-page .grw-container-convertible {
       display: block !important;
     }
 
-    .revision-toc {
-      float: none;
-      max-width: 100%;
-      margin-bottom: 20px;
-      font-size: 0.9em;
-      border: solid 1px bs.$gray-400;
-    }
-
     .meta {
       margin-top: 32px;
       color: bs.$secondary;

+ 0 - 27
packages/app/src/styles/_mixins.scss

@@ -85,33 +85,6 @@
   }
 }
 
-@mixin expand-modal-fullscreen($hasModalHeader: true, $hasModalFooter: true) {
-  // full-screen modal
-  width: auto;
-  max-width: unset !important;
-  height: calc(100vh - 30px);
-  margin: 15px !important;
-
-  .modal-content {
-    height: calc(100vh - 30px);
-  }
-
-  // expand .modal-body (with calculating height)
-  .modal-body {
-    $modal-header: 54px;
-    $modal-footer: 46px;
-
-    $margin: 0px;
-    @if $hasModalHeader {
-      $margin: $margin + $modal-header;
-    }
-    @if $hasModalFooter {
-      $margin: $margin + $modal-footer;
-    }
-    height: calc(100% - #{$margin});
-  }
-}
-
 @mixin apply-navigation-transition() {
   transition-timing-function: cubic-bezier(0.25, 1, 0.5, 1);
   transition-duration: 300ms;

+ 32 - 0
packages/app/src/styles/_modal.scss

@@ -1,4 +1,36 @@
+@mixin expand-modal-fullscreen($hasModalHeader: true, $hasModalFooter: true) {
+  // full-screen modal
+  width: auto;
+  max-width: unset !important;
+  height: calc(100vh - 30px);
+  margin: 15px !important;
+
+  .modal-content {
+    height: calc(100vh - 30px);
+  }
+
+  // expand .modal-body (with calculating height)
+  .modal-body {
+    $modal-header: 54px;
+    $modal-footer: 46px;
+
+    $margin: 0px;
+    @if $hasModalHeader {
+      $margin: $margin + $modal-header;
+    }
+    @if $hasModalFooter {
+      $margin: $margin + $modal-footer;
+    }
+    height: calc(100% - #{$margin});
+  }
+}
+
 // expanded window layout
 .modal-dialog.grw-modal-expanded {
   @include expand-modal-fullscreen(true, true);
 }
+
+// expanded window layout without modal-header & modal-footer
+.modal-dialog.grw-body-only-modal-expanded {
+  @include expand-modal-fullscreen(false, false);
+}

+ 6 - 0
packages/app/src/styles/_page-history.scss

@@ -24,6 +24,12 @@
 .revision-history-diff {
   color: $gray-900;
   table-layout: fixed;
+
+  // revision-history
+  // to stay d2h-code-side-line-number in the revision history diff area
+  .d2h-wrapper {
+    position: relative;
+  }
 }
 
 .comparison-header {

+ 2 - 7
packages/app/src/styles/_page_list.scss

@@ -1,3 +1,5 @@
+@use './bootstrap/variables' as var;
+
 body .page-list {
   .page-list-container {
     font-size: 15px;
@@ -96,10 +98,3 @@ body .page-list {
     }
   }
 }
-
-.card-timeline {
-  border: 1px solid $gray-300;
-  > .card-header {
-    background-color: $gray-300;
-  }
-}

+ 1 - 1
packages/app/src/styles/_search.scss

@@ -124,7 +124,7 @@
 
 
 // style to apply when displaying search page
-.growi.on-search {
+.on-search {
   // set sidebar height shown in search page
   $search-page-sidebar-height: calc(100vh - ($grw-navbar-height + $grw-navbar-border-width));
 

+ 2 - 8
packages/app/src/styles/_tag.scss

@@ -6,14 +6,6 @@
   }
 }
 
-.grw-tag-labels {
-  .grw-tag-label {
-    font-size: 12px;
-    font-weight: normal;
-    border-radius: bs.$border-radius;
-  }
-}
-
 .grw-popular-tag-labels {
   text-align: left;
 
@@ -33,5 +25,7 @@
 .grw-recent-changes {
   .grw-tag-label {
     font-size: 10px;
+    font-weight: normal;
+    border-radius: bs.$border-radius;
   }
 }

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor