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

Merge branch 'master' into feat/select-unrelated-group-inheritance-on-child-pge-create

Futa Arai 1 год назад
Родитель
Сommit
8030bf58e7
100 измененных файлов с 1011 добавлено и 818 удалено
  1. 2 1
      .devcontainer/devcontainer.json
  2. 2 13
      .vscode/launch.json
  3. 44 1
      CHANGELOG.md
  4. 16 0
      apps/app/config/i18next.config.js
  5. 20 9
      apps/app/config/next-i18next.config.js
  6. 1 1
      apps/app/docker/README.md
  7. 8 5
      apps/app/package.json
  8. 1 0
      apps/app/public/static/locales/en_US/commons.json
  9. 17 10
      apps/app/public/static/locales/en_US/translation.json
  10. 1 0
      apps/app/public/static/locales/fr_FR/commons.json
  11. 14 10
      apps/app/public/static/locales/fr_FR/translation.json
  12. 1 0
      apps/app/public/static/locales/ja_JP/commons.json
  13. 17 10
      apps/app/public/static/locales/ja_JP/translation.json
  14. 1 0
      apps/app/public/static/locales/zh_CN/commons.json
  15. 18 11
      apps/app/public/static/locales/zh_CN/translation.json
  16. 2 2
      apps/app/src/client/services/layout.ts
  17. 33 0
      apps/app/src/client/services/side-effects/yjs.ts
  18. 0 32
      apps/app/src/client/util/input-validator.ts
  19. 3 3
      apps/app/src/client/util/locale-utils.ts
  20. 56 0
      apps/app/src/client/util/use-input-validator.ts
  21. 4 3
      apps/app/src/components/Admin/App/ConfirmModal.tsx
  22. 2 1
      apps/app/src/components/Admin/AuditLog/ActivityTable.tsx
  23. 17 0
      apps/app/src/components/Admin/Common/AdminNavigation.module.scss
  24. 8 3
      apps/app/src/components/Admin/Common/AdminNavigation.tsx
  25. 17 8
      apps/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx
  26. 21 12
      apps/app/src/components/Admin/Customize/CustomizeSidebarSetting.tsx
  27. 7 4
      apps/app/src/components/Admin/Customize/CustomizeThemeOptions.tsx
  28. 6 0
      apps/app/src/components/Admin/Customize/ThemeColorBox.module.scss
  29. 15 5
      apps/app/src/components/Admin/Customize/ThemeColorBox.tsx
  30. 1 1
      apps/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.tsx
  31. 1 1
      apps/app/src/components/Admin/ImportData/GrowiArchive/ErrorViewer.tsx
  32. 1 1
      apps/app/src/components/Admin/ImportData/GrowiArchive/ImportCollectionConfigurationModal.jsx
  33. 1 1
      apps/app/src/components/Admin/Notification/NotificationDeleteModal.jsx
  34. 1 1
      apps/app/src/components/Admin/Security/DeleteAllShareLinksModal.jsx
  35. 1 1
      apps/app/src/components/Admin/Security/LdapAuthTestModal.jsx
  36. 1 1
      apps/app/src/components/Admin/SlackIntegration/ConfirmBotChangeModal.jsx
  37. 1 1
      apps/app/src/components/Admin/SlackIntegration/DeleteSlackBotSettingsModal.tsx
  38. 1 1
      apps/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  39. 1 1
      apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  40. 1 1
      apps/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  41. 1 1
      apps/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx
  42. 1 1
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.tsx
  43. 1 1
      apps/app/src/components/Admin/Users/PasswordResetModal.jsx
  44. 1 1
      apps/app/src/components/Admin/Users/UserInviteModal.jsx
  45. 17 6
      apps/app/src/components/AuthorInfo/AuthorInfo.tsx
  46. 31 17
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  47. 53 15
      apps/app/src/components/Bookmarks/BookmarkFolderNameInput.tsx
  48. 15 10
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  49. 68 0
      apps/app/src/components/Bookmarks/BookmarkItemRenameInput.tsx
  50. 0 1
      apps/app/src/components/Bookmarks/BookmarkMoveToRootBtn.tsx
  51. 0 151
      apps/app/src/components/Common/ClosableTextInput.tsx
  52. 27 6
      apps/app/src/components/Common/CopyDropdown/CopyDropdown.jsx
  53. 5 0
      apps/app/src/components/Common/CopyDropdown/CopyDropdown.module.scss
  54. 1 1
      apps/app/src/components/Common/Dropdown/PageItemControl.spec.tsx
  55. 2 2
      apps/app/src/components/Common/Dropdown/PageItemControl.tsx
  56. 3 4
      apps/app/src/components/Common/ImageCropModal.tsx
  57. 30 0
      apps/app/src/components/Common/SubmittableInput/AutosizeSubmittableInput.tsx
  58. 23 0
      apps/app/src/components/Common/SubmittableInput/SubmittableInput.tsx
  59. 2 0
      apps/app/src/components/Common/SubmittableInput/index.ts
  60. 7 0
      apps/app/src/components/Common/SubmittableInput/types.d.ts
  61. 80 0
      apps/app/src/components/Common/SubmittableInput/use-submittable.ts
  62. 1 1
      apps/app/src/components/CompleteUserRegistrationForm.tsx
  63. 1 1
      apps/app/src/components/ContentLinkButtons.tsx
  64. 1 1
      apps/app/src/components/CreateTemplateModal.tsx
  65. 1 1
      apps/app/src/components/DeleteBookmarkFolderModal.tsx
  66. 3 2
      apps/app/src/components/EmptyTrashModal.tsx
  67. 0 153
      apps/app/src/components/ItemsTree/ItemsTree.module.scss
  68. 4 57
      apps/app/src/components/ItemsTree/ItemsTree.tsx
  69. 11 0
      apps/app/src/components/ItemsTree/ItemsTreeContentSkeleton.module.scss
  70. 5 7
      apps/app/src/components/ItemsTree/ItemsTreeContentSkeleton.tsx
  71. 0 51
      apps/app/src/components/Layout/Admin.module.scss
  72. 3 2
      apps/app/src/components/Layout/BasicLayout.tsx
  73. 1 1
      apps/app/src/components/Me/AssociateModal.tsx
  74. 1 1
      apps/app/src/components/Me/DisassociateModal.tsx
  75. 1 1
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  76. 14 2
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  77. 2 0
      apps/app/src/components/Page/DisplaySwitcher.tsx
  78. 2 2
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  79. 2 2
      apps/app/src/components/PageAttachment/DeleteAttachmentModal.tsx
  80. 2 2
      apps/app/src/components/PageAuthorInfo/PageAuthorInfo.tsx
  81. 1 1
      apps/app/src/components/PageComment/DeleteCommentModal.tsx
  82. 5 7
      apps/app/src/components/PageContentFooter.tsx
  83. 6 5
      apps/app/src/components/PageControls/PageControls.tsx
  84. 1 1
      apps/app/src/components/PageCreateModal.tsx
  85. 1 1
      apps/app/src/components/PageDuplicateModal.tsx
  86. 2 2
      apps/app/src/components/PageEditor/EditorNavbar/EditingUserList.tsx
  87. 5 0
      apps/app/src/components/PageEditor/EditorNavbar/EditorNavbar.module.scss
  88. 4 3
      apps/app/src/components/PageEditor/EditorNavbar/EditorNavbar.tsx
  89. 12 0
      apps/app/src/components/PageEditor/EditorNavbarBottom.tsx
  90. 1 1
      apps/app/src/components/PageEditor/GridEditModal.jsx
  91. 1 1
      apps/app/src/components/PageEditor/LinkEditModal.tsx
  92. 2 15
      apps/app/src/components/PageEditor/PageEditor.tsx
  93. 31 4
      apps/app/src/components/PageHeader/PageHeader.tsx
  94. 5 1
      apps/app/src/components/PageHeader/PagePathHeader.module.scss
  95. 62 66
      apps/app/src/components/PageHeader/PagePathHeader.tsx
  96. 5 1
      apps/app/src/components/PageHeader/PageTitleHeader.module.scss
  97. 60 39
      apps/app/src/components/PageHeader/PageTitleHeader.tsx
  98. 1 1
      apps/app/src/components/PageRenameModal.tsx
  99. 13 10
      apps/app/src/components/PageSelectModal/PageSelectModal.tsx
  100. 5 0
      apps/app/src/components/PageSelectModal/TreeItemForModal.module.scss

+ 2 - 1
.devcontainer/devcontainer.json

@@ -25,7 +25,8 @@
     "editorconfig.editorconfig",
     "esbenp.prettier-vscode",
     "shinnn.stylelint",
-    "stylelint.vscode-stylelint"
+    "stylelint.vscode-stylelint",
+    "vitest.explorer"
   ],
 
   // Uncomment the next line if you want start specific services in your Docker Compose config.

+ 2 - 13
.vscode/launch.json

@@ -18,17 +18,6 @@
       },
       {
         "type": "node",
-        "request": "launch",
-        "name": "Debug: Current File with Vitest",
-        "autoAttachChildProcesses": true,
-        "skipFiles": ["<node_internals>/**", "**/node_modules/**"],
-        "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
-        "args": ["run", "${relativeFile}"],
-        "smartStep": true,
-        "console": "integratedTerminal"
-      },
-      {
-        "type": "pwa-node",
         "request": "attach",
         "name": "Debug: Attach Debugger to Server",
         "port": 9229,
@@ -38,7 +27,7 @@
         }
       },
       {
-        "type": "pwa-node",
+        "type": "node",
         "request": "launch",
         "name": "Debug: Server",
         "cwd": "${workspaceFolder}/apps/app",
@@ -57,7 +46,7 @@
         }
       },
       {
-        "type": "pwa-chrome",
+        "type": "chrome",
         "request": "launch",
         "name": "Debug: Chrome",
         "sourceMaps": true,

+ 44 - 1
CHANGELOG.md

@@ -1,9 +1,52 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v7.0.2...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v7.0.3...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.0.3](https://github.com/weseek/growi/compare/v7.0.2...v7.0.3) - 2024-05-01
+
+### 🚀 Improvement
+
+* imprv: Rename on blur (#8777) @yuki-takei
+* imprv: Re-calculate max-width for page tile on editor (#8775) @yuki-takei
+* imprv: Truncate page path title in editor (#8726) @reiji-h
+* imprv: FootstrampIcon uses material symbols (#8699) @kazutoweseek
+* imprv: behaviour when pressing enter after numerical input (ex: 2.3.4.) (#8754) @WNomunomu
+
+### 🐛 Bug Fixes
+
+* fix: An error occurred during user activation via email (#8767) @maeshinshin
+* fix: Admin customize screen (#8765) @yuki-takei
+* fix: Editor theme active line (material, nord) (#8762) @satof3
+* fix: 500 error occur when pressing the button for opening PageSelectModal (#8761) @WNomunomu
+* fix: Admin screen min-height for body (#8764) @yuki-takei
+* fix: Behaviour of table operation by enter key in editor (#8756) @WNomunomu
+* fix: EditingUserList shows user icons even when the user is not opening the editor (#8752) @miya
+* fix: Tags are not created when space key is entered (Firefox) (#8758) @miya
+* fix: Parent group selection dropdown does not work (#8759) @miya
+* fix: Codeblock overflow (#8753) @yuki-takei
+* fix:  Nord and Original-dark theme in editor (#8748) @satof3
+* fix: Material and Eclipse colors (#8749) @satof3
+* fix: Always autofocus when moving to the Editor (#8730) @reiji-h
+* fix: CopyDropdown protrudes from the screen issue (#8734) @WNomunomu
+* fix: Error on today's memo create from hotkey when memo exists (#8746) @arafubeatbox
+* fix: Editor styles (#8742) @yuki-takei
+* fix: Close the comment editor after the post (#8741) @yuki-takei
+
+### 🧰 Maintenance
+
+* ci(deps): bump typeorm from 0.3.0 to 0.3.20 (#8745) @dependabot
+* support: Make AwsFileUploader type safe (#8780) @yuki-takei
+* support: Update modal header design (#8766) @satof3
+* support: Improve types in IPage and IRevision (#8763) @yuki-takei
+* support: Editor mobile appearance (#8760) @satof3
+* support: Localize in app tags language en change ja ch revision (#8757) @kazutoweseek
+* support: Upgrade vite and vitest (#8743) @yuki-takei
+* support: Adjust design details (#8738) @satof3
+* ci(deps): bump typeorm from 0.2.32 to 0.3.0 (#8602) @dependabot
+* support: Upgrade date-fns (#8744) @yuki-takei
+
 ## [v7.0.2](https://github.com/weseek/growi/compare/v7.0.1...v7.0.2) - 2024-04-17
 
 ### 💎 Features

+ 16 - 0
apps/app/config/i18next.config.js

@@ -0,0 +1,16 @@
+const { Lang, AllLang } = require('@growi/core');
+
+/** @type {Lang} */
+const defaultLang = Lang.en_US;
+
+/** @type {import('i18next').InitOptions} */
+const initOptions = {
+  fallbackLng: defaultLang.toString(),
+  supportedLngs: AllLang,
+  defaultNS: 'translation',
+};
+
+module.exports = {
+  defaultLang,
+  initOptions,
+};

+ 20 - 9
apps/app/config/next-i18next.config.js

@@ -2,31 +2,41 @@ const isDev = process.env.NODE_ENV === 'development';
 
 const path = require('path');
 
-const { AllLang, Lang } = require('@growi/core');
+const { AllLang } = require('@growi/core');
 const { isServer } = require('@growi/core/dist/utils');
-const I18nextChainedBackend = isDev ? require('i18next-chained-backend').default : undefined;
-const I18NextHttpBackend = require('i18next-http-backend').default;
-const I18NextLocalStorageBackend = require('i18next-localstorage-backend').default;
+
+const { defaultLang } = require('./i18next.config');
 
 const HMRPlugin = isDev ? require('i18next-hmr/plugin').HMRPlugin : undefined;
 
+/** @type {import('next-i18next').UserConfig} */
 module.exports = {
-  defaultLang: Lang.en_US,
+  ...require('./i18next.config').initOptions,
+
   i18n: {
-    defaultLocale: Lang.en_US,
+    defaultLocale: defaultLang.toString(),
     locales: AllLang,
   },
-  defaultNS: 'translation',
+
   localePath: path.resolve('./public/static/locales'),
   serializeConfig: false,
+
   // eslint-disable-next-line no-nested-ternary
   use: isDev
     ? isServer()
       ? [new HMRPlugin({ webpack: { server: true } })]
-      : [I18nextChainedBackend, new HMRPlugin({ webpack: { client: true } })]
+      : [
+        require('i18next-chained-backend').default,
+        new HMRPlugin({ webpack: { client: true } }),
+      ]
     : [],
   backend: {
-    backends: isServer() ? [] : [I18NextLocalStorageBackend, I18NextHttpBackend],
+    backends: isServer()
+      ? []
+      : [
+        require('i18next-localstorage-backend').default,
+        require('i18next-http-backend').default,
+      ],
     backendOptions: [
       // options for i18next-localstorage-backend
       { expirationTime: isDev ? 0 : 24 * 60 * 60 * 1000 }, // 1 day in production
@@ -34,4 +44,5 @@ module.exports = {
       { loadPath: '/static/locales/{{lng}}/{{ns}}.json' },
     ],
   },
+
 };

+ 1 - 1
apps/app/docker/README.md

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`7.0.2`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.2/apps/app/docker/Dockerfile)
+* [`7.0.3`, `7.0`, `7`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v7.0.3/apps/app/docker/Dockerfile)
 * [`6.3.2`, `6.3`, `6` (Dockerfile)](https://github.com/weseek/growi/blob/v6.3.2/apps/app/docker/Dockerfile)
 * [`6.2.4`, `6.2` (Dockerfile)](https://github.com/weseek/growi/blob/v6.2.4/apps/app/docker/Dockerfile)
 * [`6.1.15`, `6.1` (Dockerfile)](https://github.com/weseek/growi/blob/v6.1.15/apps/app/docker/Dockerfile)

+ 8 - 5
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.0.3-RC.0",
+  "version": "7.0.4-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -108,7 +108,7 @@
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
     "diff_match_patch": "^0.1.1",
-    "ejs": "^3.1.8",
+    "ejs": "^3.1.10",
     "esa-node": "^0.2.2",
     "escape-string-regexp": "^4.0.0",
     "eslint-plugin-regex": "^1.8.0",
@@ -125,9 +125,7 @@
     "helmet": "^4.6.0",
     "http-errors": "^2.0.0",
     "i18next": "^23.10.1",
-    "i18next-chained-backend": "^4.6.2",
-    "i18next-http-backend": "^2.5.0",
-    "i18next-localstorage-backend": "^4.2.0",
+    "i18next-resources-to-backend": "^1.2.1",
     "is-absolute-url": "^4.0.1",
     "is-iso-date": "^0.0.1",
     "ldapjs": "^3.0.2",
@@ -233,6 +231,7 @@
     "@testing-library/user-event": "^14.5.2",
     "@types/express": "^4.17.11",
     "@types/jest": "^29.5.2",
+    "@types/react-input-autosize": "^2.2.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-stickynode": "^4.0.3",
     "@types/throttle-debounce": "^5.0.1",
@@ -244,6 +243,7 @@
     "babel-loader": "^8.2.5",
     "bootstrap": "^5.3.1",
     "connect-browser-sync": "^2.1.0",
+    "cypress-real-events": "^1.12.0",
     "diff2html": "^3.4.47",
     "downshift": "^8.2.3",
     "eazy-logger": "^3.1.0",
@@ -253,7 +253,10 @@
     "fslightbox-react": "^1.7.6",
     "handsontable": "=6.2.2",
     "happy-dom": "^13.2.0",
+    "i18next-chained-backend": "^4.6.2",
     "i18next-hmr": "^3.0.4",
+    "i18next-http-backend": "^2.5.0",
+    "i18next-localstorage-backend": "^4.2.0",
     "jest": "^29.5.0",
     "jest-date-mock": "^1.0.8",
     "jest-localstorage-mock": "^2.4.14",

+ 1 - 0
apps/app/public/static/locales/en_US/commons.json

@@ -77,6 +77,7 @@
 
   "create_page_dropdown": {
     "new_page": "Create New Page",
+    "open_page_create_modal": "Open new page create modal",
     "todays": {
       "desc": "Create today's memo",
       "memo": "memo"

+ 17 - 10
apps/app/public/static/locales/en_US/translation.json

@@ -160,16 +160,20 @@
   "not_allowed_to_see_this_page": "You cannot see this page",
   "Confirm": "Confirm",
   "Successfully requested": "Successfully requested.",
-  "form_validation": {
-    "error_message": "Some values ​​are incorrect",
-    "required": "%s is required",
-    "invalid_syntax": "The syntax of %s is invalid.",
-    "title_required": "Title is required.",
-    "field_required": "{{target}} is required"
-  },
-  "page_name": "Page name",
-  "folder_name": "Folder name",
-  "field": "field",
+  "input_validation": {
+    "target": {
+      "page_name": "Page name",
+      "folder_name": "Folder name",
+      "field": "field"
+    },
+    "message": {
+      "error_message": "Some values ​​are incorrect",
+      "required": "%s is required",
+      "invalid_syntax": "The syntax of %s is invalid.",
+      "title_required": "Title is required.",
+      "field_required": "{{target}} is required"
+    }
+  },
   "not_creatable_page": {
     "message": "Page contents cannot be created in this path."
   },
@@ -869,5 +873,8 @@
     "show_wip_page": "Show WIP",
     "size_s": "Size: S",
     "size_l": "Size: L"
+  },
+  "create_page": {
+    "untitled": "Untitled"
   }
 }

+ 1 - 0
apps/app/public/static/locales/fr_FR/commons.json

@@ -77,6 +77,7 @@
 
   "create_page_dropdown": {
     "new_page": "Créer nouvelle page",
+    "open_page_create_modal": "Ouvrir une nouvelle page créer une fenêtre modale",
     "todays": {
       "desc": "Créer le mémo du jour",
       "memo": "mémo"

+ 14 - 10
apps/app/public/static/locales/fr_FR/translation.json

@@ -160,16 +160,20 @@
   "not_allowed_to_see_this_page": "Vous ne pouvez pas voir cette page",
   "Confirm": "Confirmer",
   "Successfully requested": "Demande envoyée.",
-  "form_validation": {
-    "error_message": "Des champs sont invalides",
-    "required": "%s est requis",
-    "invalid_syntax": "La syntaxe de %s est invalide.",
-    "title_required": "Titre requis.",
-    "field_required": "{{target}} est requis"
-  },
-  "page_name": "Nom de la page",
-  "folder_name": "Nom du dossier",
-  "field": "champ",
+  "input_validation": {
+    "target": {
+      "page_name": "Nom de la page",
+      "folder_name": "Nom du dossier",
+      "field": "champ"
+    },
+    "message": {
+      "error_message": "Des champs sont invalides",
+      "required": "%s est requis",
+      "invalid_syntax": "La syntaxe de %s est invalide.",
+      "title_required": "Titre requis.",
+      "field_required": "{{target}} est requis"
+    }
+  },
   "not_creatable_page": {
     "message": "Vous ne pouvez pas créer cette page dans ce chemin."
   },

+ 1 - 0
apps/app/public/static/locales/ja_JP/commons.json

@@ -79,6 +79,7 @@
 
   "create_page_dropdown": {
     "new_page": "新規ページ作成",
+    "open_page_create_modal": "新規ページ作成モーダルを表示",
     "todays": {
       "desc": "今日のメモを作成",
       "memo": "メモ"

+ 17 - 10
apps/app/public/static/locales/ja_JP/translation.json

@@ -161,16 +161,20 @@
   "not_allowed_to_see_this_page": "このページは閲覧できません",
   "Confirm": "確認",
   "Successfully requested": "正常に処理を受け付けました",
-  "form_validation": {
-    "error_message": "いくつかの値が設定されていません",
-    "required": "%sに値を入力してください",
-    "invalid_syntax": "%sの構文が不正です",
-    "title_required": "タイトルを入力してください",
-    "field_required": "{{target}}に値を入力してください"
-  },
-  "page_name": "ページ名",
-  "folder_name": "フォルダ名",
-  "field": "フィールド",
+  "input_validation": {
+    "target": {
+      "page_name": "ページ名",
+      "folder_name": "フォルダ名",
+      "field": "フィールド"
+    },
+    "message": {
+      "error_message": "いくつかの値が設定されていません",
+      "required": "%sに値を入力してください",
+      "invalid_syntax": "%sの構文が不正です",
+      "title_required": "タイトルを入力してください",
+      "field_required": "{{target}}に値を入力してください"
+    }
+  },
   "not_creatable_page": {
     "message": "このパスではページ コンテンツを作成できません。"
   },
@@ -902,5 +906,8 @@
     "show_wip_page": "WIP を表示",
     "size_s": "サイズ: S",
     "size_l": "サイズ: L"
+  },
+  "create_page": {
+    "untitled": "無題のページ"
   }
 }

+ 1 - 0
apps/app/public/static/locales/zh_CN/commons.json

@@ -80,6 +80,7 @@
 
   "create_page_dropdown": {
     "new_page": "新页面",
+    "open_page_create_modal": "打开新页面创建模式",
     "todays": {
       "desc": "Create today's memo",
       "memo": "memo"

+ 18 - 11
apps/app/public/static/locales/zh_CN/translation.json

@@ -27,7 +27,7 @@
   "Description": "描述",
   "Admin": "管理",
   "administrator": "管理员",
-  "Tags": "Tags",
+  "Tags": "标签",
   "Close": "Close",
   "Shortcuts": "快捷方式",
   "CustomSidebar": "Custom Sidebar",
@@ -167,16 +167,20 @@
   "Confirm": "确定",
   "Successfully requested": "进程成功接受",
   "copied_to_clipboard": "它已复制到剪贴板。",
-  "form_validation": {
-    "error_message": "有些值不正确",
-    "required": "%s 是必需的",
-    "invalid_syntax": "%s的语法无效。",
-    "title_required": "标题是必需的。",
-    "field_required": "{{target}} 是必需的"
-  },
-  "page_name": "页面名称",
-  "folder_name": "文件夹名称",
-  "field": "字段",
+  "input_validation": {
+    "target": {
+      "page_name": "页面名称",
+      "folder_name": "文件夹名称",
+      "field": "字段"
+    },
+    "message": {
+      "error_message": "有些值不正确",
+      "required": "%s 是必需的",
+      "invalid_syntax": "%s的语法无效。",
+      "title_required": "标题是必需的。",
+      "field_required": "{{target}} 是必需的"
+    }
+  },
   "not_creatable_page": {
     "message": "无法在此路径中创建页面内容。"
   },
@@ -872,5 +876,8 @@
     "show_wip_page": "显示 WIP",
     "size_s": "尺寸: S",
     "size_l": "尺寸: L"
+  },
+  "create_page": {
+    "untitled": "Untitled"
   }
 }

+ 2 - 2
apps/app/src/client/services/layout.ts

@@ -1,4 +1,4 @@
-import type { IPage } from '@growi/core';
+import type { IPage, IPagePopulatedToShowRevision } from '@growi/core';
 
 import { useIsContainerFluid } from '~/stores/context';
 import { useEditorMode } from '~/stores/ui';
@@ -16,7 +16,7 @@ const useDetermineExpandContent = (expandContentWidth?: boolean | null): boolean
   return expandContentWidth ?? isContainerFluidDefault ?? false;
 };
 
-export const useShouldExpandContent = (data?: IPage | boolean | null): boolean => {
+export const useShouldExpandContent = (data?: IPage | IPagePopulatedToShowRevision | boolean | null): boolean => {
   const expandContentWidth = (() => {
     // when data is null
     if (data == null) {

+ 33 - 0
apps/app/src/client/services/side-effects/yjs.ts

@@ -0,0 +1,33 @@
+import { useCallback, useEffect } from 'react';
+
+import { useGlobalSocket } from '@growi/core/dist/swr';
+
+import { SocketEventName } from '~/interfaces/websocket';
+import { useCurrentPageYjsData } from '~/stores/yjs';
+
+export const useCurrentPageYjsDataEffect = (): void => {
+  const { data: socket } = useGlobalSocket();
+  const { updateHasRevisionBodyDiff, updateAwarenessStateSize } = useCurrentPageYjsData();
+
+  const hasRevisionBodyDiffUpdateHandler = useCallback((hasRevisionBodyDiff: boolean) => {
+    updateHasRevisionBodyDiff(hasRevisionBodyDiff);
+  }, [updateHasRevisionBodyDiff]);
+
+  const awarenessStateSizeUpdateHandler = useCallback(((awarenessStateSize: number) => {
+    updateAwarenessStateSize(awarenessStateSize);
+  }), [updateAwarenessStateSize]);
+
+  useEffect(() => {
+
+    if (socket == null) { return }
+
+    socket.on(SocketEventName.YjsHasRevisionBodyDiffUpdated, hasRevisionBodyDiffUpdateHandler);
+    socket.on(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSizeUpdateHandler);
+
+    return () => {
+      socket.off(SocketEventName.YjsHasRevisionBodyDiffUpdated, hasRevisionBodyDiffUpdateHandler);
+      socket.off(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSizeUpdateHandler);
+    };
+
+  }, [socket, awarenessStateSizeUpdateHandler, hasRevisionBodyDiffUpdateHandler]);
+};

+ 0 - 32
apps/app/src/client/util/input-validator.ts

@@ -1,32 +0,0 @@
-export const AlertType = {
-  WARNING: 'warning',
-  ERROR: 'error',
-} as const;
-
-export type AlertType = typeof AlertType[keyof typeof AlertType];
-
-export const ValidationTarget = {
-  FOLDER: 'folder_name',
-  PAGE: 'page_name',
-  DEFAULT: 'field',
-};
-
-export type ValidationTarget = typeof ValidationTarget[keyof typeof ValidationTarget];
-
-export type AlertInfo = {
-  type?: AlertType
-  message?: string,
-  target?: string
-}
-
-export const inputValidator = async(title: string | null, target?: string): Promise<AlertInfo | null> => {
-  const validationTarget = target || ValidationTarget.DEFAULT;
-  if (title == null || title === '' || title.trim() === '') {
-    return {
-      type: AlertType.WARNING,
-      message: 'form_validation.field_required',
-      target: validationTarget,
-    };
-  }
-  return null;
-};

+ 3 - 3
apps/app/src/client/util/locale-utils.ts

@@ -2,7 +2,7 @@ import type { IncomingHttpHeaders } from 'http';
 
 import { Lang } from '@growi/core';
 
-import * as nextI18NextConfig from '^/config/next-i18next.config';
+import { defaultLang } from '^/config/i18next.config';
 
 // https://docs.google.com/spreadsheets/d/1FoYdyEraEQuWofzbYCDPKN7EdKgS_2ZrsDrOA8scgwQ
 const DIAGRAMS_NET_LANG_MAP = {
@@ -31,7 +31,7 @@ const getPreferredLanguage = (sortedAcceptLanguagesArray: string[]): Lang => {
     const matchingLang = Object.keys(ACCEPT_LANG_MAP).find(key => lang.includes(key));
     if (matchingLang) return ACCEPT_LANG_MAP[matchingLang];
   }
-  return nextI18NextConfig.defaultLang;
+  return defaultLang;
 };
 
 /**
@@ -44,7 +44,7 @@ export const detectLocaleFromBrowserAcceptLanguage = (headers: IncomingHttpHeade
   const acceptLanguages = headers['accept-language'];
 
   if (acceptLanguages == null) {
-    return nextI18NextConfig.defaultLang;
+    return defaultLang;
   }
 
   // 1. trim blank spaces.

+ 56 - 0
apps/app/src/client/util/use-input-validator.ts

@@ -0,0 +1,56 @@
+import { useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+export const AlertType = {
+  WARNING: 'Warning',
+  ERROR: 'Error',
+} as const;
+
+export type AlertType = typeof AlertType[keyof typeof AlertType];
+
+export const ValidationTarget = {
+  FOLDER: 'folder_name',
+  PAGE: 'page_name',
+  DEFAULT: 'field',
+};
+
+export type ValidationTarget = typeof ValidationTarget[keyof typeof ValidationTarget];
+
+export type AlertInfo = {
+  type?: AlertType
+  message?: string,
+  target?: string
+}
+
+
+export type InputValidationResult = {
+  type: AlertType
+  typeLabel: string,
+  message: string,
+  target: string
+}
+
+export type InputValidator = (input?: string, alertType?: AlertType) => InputValidationResult | void;
+
+export const useInputValidator = (validationTarget: ValidationTarget = ValidationTarget.DEFAULT): InputValidator => {
+  const { t } = useTranslation();
+
+  const inputValidator: InputValidator = useCallback((input?, alertType = AlertType.WARNING) => {
+    if ((input ?? '').trim() === '') {
+      return {
+        target: validationTarget,
+        type: alertType,
+        typeLabel: t(alertType),
+        message: t(
+          'input_validation.message.field_required',
+          { target: t(`input_validation.target.${validationTarget}`) },
+        ),
+      };
+    }
+
+    return;
+  }, [t, validationTarget]);
+
+  return inputValidator;
+};

+ 4 - 3
apps/app/src/components/Admin/App/ConfirmModal.tsx

@@ -1,4 +1,5 @@
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 import {
@@ -31,8 +32,8 @@ export const ConfirmModal: FC<ConfirmModalProps> = (props: ConfirmModalProps) =>
 
   return (
     <Modal isOpen={props.isModalOpen} toggle={onCancel}>
-      <ModalHeader tag="h4" toggle={onCancel} className="bg-danger">
-        <span className="material-symbols-outlined">help</span>
+      <ModalHeader tag="h4" toggle={onCancel} className="text-danger">
+        <span className="material-symbols-outlined me-1">warning</span>
         {t('Warning')}
       </ModalHeader>
       <ModalBody>

+ 2 - 1
apps/app/src/components/Admin/AuditLog/ActivityTable.tsx

@@ -1,6 +1,7 @@
 import type { FC } from 'react';
 import React, { useState, useCallback } from 'react';
 
+import { isPopulated } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format } from 'date-fns/format';
@@ -51,7 +52,7 @@ export const ActivityTable : FC<Props> = (props: Props) => {
                       <UserPicture user={activity.user} />
                       <a
                         className="ms-2"
-                        href={pagePathUtils.userHomepagePath(activity.user)}
+                        href={isPopulated(activity.user) ? pagePathUtils.userHomepagePath(activity.user) : undefined}
                       >
                         {activity.snapshot?.username}
                       </a>

+ 17 - 0
apps/app/src/components/Admin/Common/AdminNavigation.module.scss

@@ -0,0 +1,17 @@
+// button layout
+.admin-navigation {
+  &:global {
+    & > a + a {
+      margin-top: 2px;
+    }
+  }
+}
+
+// sticky settings
+.admin-navigation {
+  &:global {
+    &.sticky-top {
+      top: 30px;
+    }
+  }
+}

+ 8 - 3
apps/app/src/components/Admin/Common/AdminNavigation.tsx

@@ -7,6 +7,11 @@ import urljoin from 'url-join';
 
 import { useGrowiCloudUri, useGrowiAppIdForGrowiCloud } from '../../../stores/context';
 
+import styles from './AdminNavigation.module.scss';
+
+const moduleClass = styles['admin-navigation'];
+
+
 // eslint-disable-next-line react/prop-types
 const MenuLabel = ({ menu }: { menu: string }) => {
   const { t } = useTranslation(['admin', 'commons']);
@@ -82,7 +87,7 @@ export const AdminNavigation = (): JSX.Element => {
 
   }, [pathname]);
 
-  const getListGroupItemOrDropdownItemList = (isListGroupItems: boolean) => {
+  const getListGroupItemOrDropdownItemList = useCallback((isListGroupItems: boolean) => {
     return (
       <>
         {/* eslint-disable no-multi-spaces */}
@@ -115,12 +120,12 @@ export const AdminNavigation = (): JSX.Element => {
         {/* eslint-enable no-multi-spaces */}
       </>
     );
-  };
+  }, [growiAppIdForGrowiCloud, growiCloudUri, isActiveMenu, pathname]);
 
   return (
     <React.Fragment>
       {/* List group */}
-      <div className="list-group admin-navigation sticky-top d-none d-lg-block">
+      <div className={`list-group ${moduleClass} sticky-top d-none d-lg-block`}>
         {getListGroupItemOrDropdownItemList(true)}
       </div>
 

+ 17 - 8
apps/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx

@@ -30,7 +30,6 @@ const CustomizeLayoutSetting = (): JSX.Element => {
   const { resolvedTheme } = useNextThemes();
 
   const { isContainerFluid, setIsContainerFluid, updateLayoutSetting } = useIsContainerFluid();
-  const [retrieveError, setRetrieveError] = useState<any>();
 
   const onClickSubmit = useCallback(async() => {
     if (isContainerFluid == null) { return }
@@ -58,14 +57,19 @@ const CustomizeLayoutSetting = (): JSX.Element => {
           <h2 className="admin-setting-header">{t('customize_settings.layout')}</h2>
 
           <div className="d-flex justify-content-around mt-5">
-            <div id="layoutOptions" className="row row-cols-2">
+            <div className="row row-cols-2">
               <div className="col">
                 <div
-                  className={`card customize-layout-card ${!isContainerFluid ? 'border-active' : ''}`}
+                  className={`card border border-4 ${!isContainerFluid ? 'border-primary' : ''}`}
                   onClick={() => setIsContainerFluid(false)}
                   role="button"
                 >
-                  <img src={`/images/customize-settings/default-${resolvedTheme}.svg`} />
+                  {/* eslint-disable-next-line @next/next/no-img-element */}
+                  <img
+                    className="card-img-top"
+                    src={`/images/customize-settings/default-${resolvedTheme}.svg`}
+                    alt={t('customize_settings.layout_options.default')}
+                  />
                   <div className="card-body text-center">
                     {t('customize_settings.layout_options.default')}
                   </div>
@@ -73,12 +77,17 @@ const CustomizeLayoutSetting = (): JSX.Element => {
               </div>
               <div className="col">
                 <div
-                  className={`card customize-layout-card ${isContainerFluid ? 'border-active' : ''}`}
+                  className={`card border border-4 ${isContainerFluid ? 'border-primary' : ''}`}
                   onClick={() => setIsContainerFluid(true)}
                   role="button"
                 >
-                  <img src={`/images/customize-settings/fluid-${resolvedTheme}.svg`} />
-                  <div className="card-body  text-center">
+                  {/* eslint-disable-next-line @next/next/no-img-element */}
+                  <img
+                    className="card-img-top"
+                    src={`/images/customize-settings/fluid-${resolvedTheme}.svg`}
+                    alt={t('customize_settings.layout_options.expanded')}
+                  />
+                  <div className="card-body text-center">
                     {t('customize_settings.layout_options.expanded')}
                   </div>
                 </div>
@@ -88,7 +97,7 @@ const CustomizeLayoutSetting = (): JSX.Element => {
 
           <div className="row my-3">
             <div className="mx-auto">
-              <button type="button" className="btn btn-primary" onClick={onClickSubmit} disabled={retrieveError != null}>{ t('Update') }</button>
+              <button type="button" className="btn btn-primary" onClick={onClickSubmit}>{ t('Update') }</button>
             </div>
           </div>
         </div>

+ 21 - 12
apps/app/src/components/Admin/Customize/CustomizeSidebarSetting.tsx

@@ -1,5 +1,6 @@
 import React, { useCallback } from 'react';
 
+import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import { Card, CardBody } from 'reactstrap';
 
@@ -11,7 +12,7 @@ const CustomizeSidebarsetting = (): JSX.Element => {
   const { t } = useTranslation(['admin', 'commons']);
 
   const {
-    update, isSidebarCollapsedMode, setIsSidebarCollapsedMode,
+    data, update, setIsSidebarCollapsedMode, setIsSidebarClosedAtDockMode,
   } = useSWRxSidebarConfig();
 
   const { resolvedTheme } = useNextThemes();
@@ -28,6 +29,12 @@ const CustomizeSidebarsetting = (): JSX.Element => {
     }
   }, [t, update]);
 
+  if (data == null) {
+    return <LoadingSpinner />;
+  }
+
+  const { isSidebarCollapsedMode, isSidebarClosedAtDockMode } = data;
+
   return (
     <React.Fragment>
       <div className="row">
@@ -42,14 +49,15 @@ const CustomizeSidebarsetting = (): JSX.Element => {
           </Card>
 
           <div className="d-flex justify-content-around mt-5">
-            <div id="layoutOptions" className="row row-cols-2">
+            <div className="row row-cols-2">
               <div className="col">
                 <div
-                  className={`card customize-layout-card ${isSidebarCollapsedMode ? 'border-active' : ''}`}
+                  className={`card border border-4 ${isSidebarCollapsedMode ? 'border-primary' : ''}`}
                   onClick={() => setIsSidebarCollapsedMode(true)}
                   role="button"
                 >
-                  <img src={drawerIconFileName} />
+                  {/* eslint-disable-next-line @next/next/no-img-element */}
+                  <img src={drawerIconFileName} alt="Drawer Mode" />
                   <div className="card-body text-center">
                     Drawer Mode
                   </div>
@@ -57,11 +65,12 @@ const CustomizeSidebarsetting = (): JSX.Element => {
               </div>
               <div className="col">
                 <div
-                  className={`card customize-layout-card ${!isSidebarCollapsedMode ? 'border-active' : ''}`}
+                  className={`card border border-4 ${!isSidebarCollapsedMode ? 'border-primary' : ''}`}
                   onClick={() => setIsSidebarCollapsedMode(false)}
                   role="button"
                 >
-                  <img src={dockIconFileName} />
+                  {/* eslint-disable-next-line @next/next/no-img-element */}
+                  <img src={dockIconFileName} alt="Dock Mode" />
                   <div className="card-body  text-center">
                     Dock Mode
                   </div>
@@ -82,9 +91,9 @@ const CustomizeSidebarsetting = (): JSX.Element => {
                 type="radio"
                 id="is-open"
                 className="form-check-input"
-                name="mailVisibility"
-                checked={isSidebarCollapsedMode === false}
-                onChange={() => setIsSidebarCollapsedMode(false)}
+                checked={isSidebarCollapsedMode === false && isSidebarClosedAtDockMode === false}
+                disabled={isSidebarCollapsedMode}
+                onChange={() => setIsSidebarClosedAtDockMode(false)}
               />
               <label className="form-label form-check-label" htmlFor="is-open">
                 {t('customize_settings.default_sidebar_mode.dock_mode_default_open')}
@@ -95,9 +104,9 @@ const CustomizeSidebarsetting = (): JSX.Element => {
                 type="radio"
                 id="is-closed"
                 className="form-check-input"
-                name="mailVisibility"
-                checked={isSidebarCollapsedMode === true}
-                onChange={() => setIsSidebarCollapsedMode(true)}
+                checked={isSidebarCollapsedMode === false && isSidebarClosedAtDockMode === true}
+                disabled={isSidebarCollapsedMode}
+                onChange={() => setIsSidebarClosedAtDockMode(true)}
               />
               <label className="form-label form-check-label" htmlFor="is-closed">
                 {t('customize_settings.default_sidebar_mode.dock_mode_default_close')}

+ 7 - 4
apps/app/src/components/Admin/Customize/CustomizeThemeOptions.tsx

@@ -25,11 +25,12 @@ const CustomizeThemeOptions = (props: Props): JSX.Element => {
   }, [availableThemes]);
 
   return (
-    <div id="themeOptions">
+    <>
+
       {/* Light and Dark Themes */}
       <div>
         <h3>{t('customize_settings.theme_desc.light_and_dark')}</h3>
-        <div className="d-flex flex-wrap">
+        <div className="hstack gap-3">
           {lightNDarkThemes.map((theme) => {
             return (
               <ThemeColorBox
@@ -42,10 +43,11 @@ const CustomizeThemeOptions = (props: Props): JSX.Element => {
           })}
         </div>
       </div>
+
       {/* Only one mode Theme */}
       <div className="mt-3">
         <h3>{t('customize_settings.theme_desc.unique')}</h3>
-        <div className="d-flex flex-wrap">
+        <div className="hstack gap-3">
           {oneModeThemes.map((theme) => {
             return (
               <ThemeColorBox
@@ -58,7 +60,8 @@ const CustomizeThemeOptions = (props: Props): JSX.Element => {
           })}
         </div>
       </div>
-    </div>
+
+    </>
   );
 
 };

+ 6 - 0
apps/app/src/components/Admin/Customize/ThemeColorBox.module.scss

@@ -0,0 +1,6 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+// layout
+.theme-option-container :global {
+  min-width: 100px;
+}

+ 15 - 5
apps/app/src/components/Admin/Customize/ThemeColorBox.tsx

@@ -2,6 +2,10 @@ import React from 'react';
 
 import type { GrowiThemeMetadata } from '@growi/core';
 
+import styles from './ThemeColorBox.module.scss';
+
+const themeOptionClass = styles['theme-option-container'];
+
 
 type Props = {
   isSelected: boolean,
@@ -19,13 +23,19 @@ export const ThemeColorBox = (props: Props): JSX.Element => {
   } = metadata;
 
   return (
-    // TODO: Display a primary color border when icon is selected
     <div
       id={`theme-option-${name}`}
-      className={`theme-option-container d-flex flex-column align-items-center ${isSelected ? 'active' : ''}`}
+      className={`${themeOptionClass} d-flex flex-column align-items-center ${isSelected ? 'active' : ''}`}
       onClick={onSelected}
     >
-      <a id={name} role="button" className={`m-0 rounded ${name} theme-button`}>
+      <a
+        id={name}
+        role="button"
+        className={`
+          m-0 rounded rounded-3
+          border border-4 border-primary ${isSelected ? '' : 'border-opacity-10'}`
+        }
+      >
         <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" className="rounded">
           <path d="M32.5,0V36.364L64,20.437V0Z" fill={lightBg} />
           <path d="M32.5,36.364V64H64V20.438Z" fill={darkBg} />
@@ -45,8 +55,8 @@ export const ThemeColorBox = (props: Props): JSX.Element => {
           <rect width="19.629" height="2.062" transform="translate(6.436 53.439)" fill={darkIcon} />
         </svg>
       </a>
-      <span className="theme-option-name mt-2"><b>{ name }</b></span>
-      { !isPresetTheme && <span className="theme-option-badge badge bg-primary mt-1">Plugin</span> }
+      <span className={`mt-2 ${isSelected ? '' : 'opacity-25'}`}><b>{ name }</b></span>
+      { !isPresetTheme && <span className={`badge bg-primary mt-1 ${isSelected ? '' : 'opacity-25'}`}>Plugin</span> }
     </div>
   );
 

+ 1 - 1
apps/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.tsx

@@ -157,7 +157,7 @@ const SelectCollectionsModal = (props: Props): JSX.Element => {
 
   return (
     <Modal isOpen={isOpen} toggle={onClose}>
-      <ModalHeader tag="h4" toggle={onClose} className="bg-info text-light">
+      <ModalHeader tag="h4" toggle={onClose} className="text-info">
         {t('admin:export_management.export_collections')}
       </ModalHeader>
 

+ 1 - 1
apps/app/src/components/Admin/ImportData/GrowiArchive/ErrorViewer.tsx

@@ -21,7 +21,7 @@ const ErrorViewer = (props: ErrorViewerProps): JSX.Element => {
 
   return (
     <Modal isOpen={props.isOpen} toggle={props.onClose} size="lg">
-      <ModalHeader tag="h4" toggle={props.onClose} className="bg-danger text-light">
+      <ModalHeader tag="h4" toggle={props.onClose} className="text-danger">
         Errors
       </ModalHeader>
       <ModalBody>

+ 1 - 1
apps/app/src/components/Admin/ImportData/GrowiArchive/ImportCollectionConfigurationModal.jsx

@@ -190,7 +190,7 @@ class ImportCollectionConfigurationModal extends React.Component {
 
     return (
       <Modal isOpen={this.props.isOpen} toggle={this.props.onClose} onEnter={this.initialize}>
-        <ModalHeader tag="h4" toggle={this.props.onClose} className="bg-info text-light">
+        <ModalHeader tag="h4" toggle={this.props.onClose} className="text-info">
           {`'${collectionName}'`} Configuration
         </ModalHeader>
 

+ 1 - 1
apps/app/src/components/Admin/Notification/NotificationDeleteModal.jsx

@@ -12,7 +12,7 @@ class NotificationDeleteModal extends React.PureComponent {
     const { t, notificationForConfiguration } = this.props;
     return (
       <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
-        <ModalHeader tag="h4" toggle={this.props.onClose} className="bg-danger text-light">
+        <ModalHeader tag="h4" toggle={this.props.onClose} className="text-danger">
           <span className="material-symbols-outlined">delete_forever</span>Delete Global Notification Setting
         </ModalHeader>
         <ModalBody>

+ 1 - 1
apps/app/src/components/Admin/Security/DeleteAllShareLinksModal.jsx

@@ -20,7 +20,7 @@ const DeleteAllShareLinksModal = React.memo((props) => {
 
   return (
     <Modal isOpen={props.isOpen} toggle={closeButtonHandler} className="page-comment-delete-modal">
-      <ModalHeader tag="h4" toggle={closeButtonHandler} className="bg-danger text-light">
+      <ModalHeader tag="h4" toggle={closeButtonHandler} className="text-danger">
         <span>
           <span className="material-symbols-outlined">delete_forever</span>
           {t('security_settings.delete_all_share_links')}

+ 1 - 1
apps/app/src/components/Admin/Security/LdapAuthTestModal.jsx

@@ -44,7 +44,7 @@ class LdapAuthTestModal extends React.Component {
 
     return (
       <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
-        <ModalHeader tag="h4" toggle={this.props.onClose} className="bg-info text-light">
+        <ModalHeader tag="h4" toggle={this.props.onClose} className="text-info">
           Test LDAP Account
         </ModalHeader>
         <ModalBody>

+ 1 - 1
apps/app/src/components/Admin/SlackIntegration/ConfirmBotChangeModal.jsx

@@ -25,7 +25,7 @@ const ConfirmBotChangeModal = (props) => {
     <Modal isOpen={props.isOpen} centered>
       <ModalHeader
         toggle={handleCancelButton}
-        className="bg-danger"
+        className="text-danger"
       >
         {t('slack_integration.modal.warning')}
       </ModalHeader>

+ 1 - 1
apps/app/src/components/Admin/SlackIntegration/DeleteSlackBotSettingsModal.tsx

@@ -31,7 +31,7 @@ export const DeleteSlackBotSettingsModal = React.memo((props: DeleteSlackBotSett
 
   return (
     <Modal isOpen={isOpen} toggle={closeButtonHandler} className="page-comment-delete-modal">
-      <ModalHeader tag="h4" toggle={closeButtonHandler} className="bg-danger text-light">
+      <ModalHeader tag="h4" toggle={closeButtonHandler} className="text-danger">
         <span>
           {isResetAll && (
             <>

+ 1 - 1
apps/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx

@@ -177,7 +177,7 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
 
   return (
     <Modal className="modal-md" isOpen={props.isShow} toggle={toggleHandler}>
-      <ModalHeader tag="h4" toggle={toggleHandler} className="bg-danger text-light">
+      <ModalHeader tag="h4" toggle={toggleHandler}>
         <span className="material-symbols-outlined">delete_forever</span> {t('admin:user_group_management.delete_modal.header')}
       </ModalHeader>
       <ModalBody>

+ 1 - 1
apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -116,7 +116,7 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
             <button
               type="button"
               id="dropdownMenuButton"
-              data-toggle="dropdown"
+              data-bs-toggle="dropdown"
               className="btn btn-outline-secondary dropdown-toggle mb-3"
               disabled={isExternalGroup || !isSelectableParentUserGroups}
             >

+ 1 - 1
apps/app/src/components/Admin/UserGroup/UserGroupModal.tsx

@@ -69,7 +69,7 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
   return (
     <Modal className="modal-md" isOpen={isShow} toggle={onHide}>
       <form onSubmit={onSubmitHandler}>
-        <ModalHeader tag="h4" toggle={onHide} className="bg-primary text-light">
+        <ModalHeader tag="h4" toggle={onHide}>
           {t('user_group_management.basic_info')}
         </ModalHeader>
 

+ 1 - 1
apps/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx

@@ -27,7 +27,7 @@ export const UpdateParentConfirmModal: FC = () => {
 
   return (
     <Modal className="modal-md" isOpen={isOpened} toggle={closeModal}>
-      <ModalHeader tag="h4" toggle={closeModal} className="bg-warning text-light">
+      <ModalHeader tag="h4" toggle={closeModal} className="text-warning">
         <span className="material-symbols-outlined">warning</span> {t('admin:user_group_management.update_parent_confirm_modal.header')}
       </ModalHeader>
       {

+ 1 - 1
apps/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.tsx

@@ -45,7 +45,7 @@ const UserGroupUserModal = (props: Props): JSX.Element => {
 
   return (
     <Modal isOpen={isOpen} toggle={onClose}>
-      <ModalHeader tag="h4" toggle={onClose} className="bg-info text-light">
+      <ModalHeader tag="h4" toggle={onClose} className="text-info">
         {t('admin:user_group_management.add_modal.add_user') }
       </ModalHeader>
       <ModalBody>

+ 1 - 1
apps/app/src/components/Admin/Users/PasswordResetModal.jsx

@@ -187,7 +187,7 @@ class PasswordResetModal extends React.Component {
 
     return (
       <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
-        <ModalHeader tag="h4" toggle={this.props.onClose} className="bg-warning text-light">
+        <ModalHeader tag="h4" toggle={this.props.onClose} className="text-warning">
           {t('user_management.reset_password') }
         </ModalHeader>
         <ModalBody>

+ 1 - 1
apps/app/src/components/Admin/Users/UserInviteModal.jsx

@@ -261,7 +261,7 @@ class UserInviteModal extends React.Component {
 
     return (
       <Modal isOpen={adminUsersContainer.state.isUserInviteModalShown}>
-        <ModalHeader tag="h4" toggle={this.onToggleModal} className="bg-info text-light">
+        <ModalHeader tag="h4" toggle={this.onToggleModal} className="text-info">
           {t('admin:user_management.invite_users') }
         </ModalHeader>
         <ModalBody>

+ 17 - 6
apps/app/src/components/AuthorInfo/AuthorInfo.tsx

@@ -1,6 +1,7 @@
 import React from 'react';
 
-import type { IUser } from '@growi/core';
+import type { IUserHasId } from '@growi/core';
+import { isPopulated, type IUser, type Ref } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format } from 'date-fns/format';
@@ -10,10 +11,22 @@ import Link from 'next/link';
 
 import styles from './AuthorInfo.module.scss';
 
+const UserLabel = ({ user }: { user: IUserHasId | Ref<IUser> }): JSX.Element => {
+  if (isPopulated(user)) {
+    return (
+      <Link href={pagePathUtils.userHomepagePath(user)} prefetch={false}>
+        {user.name}
+      </Link>
+    );
+  }
+
+  return <i>(anyone)</i>;
+};
+
 
-export type AuthorInfoProps = {
+type AuthorInfoProps = {
   date: Date,
-  user: IUser,
+  user?: IUserHasId | Ref<IUser>,
   mode: 'create' | 'update',
   locate: 'subnav' | 'footer',
 }
@@ -37,9 +50,7 @@ export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
     : t('author_info.last_revision_posted_at');
   const userLabel = user != null
     ? (
-      <Link href={pagePathUtils.userHomepagePath(user)} prefetch={false}>
-        {user.name}
-      </Link>
+      <UserLabel user={user} />
     )
     : <i>Unknown</i>;
 

+ 31 - 17
apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -32,6 +32,7 @@ type BookmarkFolderItemProps = {
 }
 
 export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderItemProps) => {
+
   const BASE_FOLDER_PADDING = 15;
   const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const {
@@ -59,23 +60,36 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     setTargetFolder(folderId);
   }, [folderId, isOpen]);
 
+  const cancel = useCallback(() => {
+    setIsRenameAction(false);
+    setIsCreateAction(false);
+  }, []);
+
   // Rename for bookmark folder handler
-  const onPressEnterHandlerForRename = useCallback(async(folderName: string) => {
+  const rename = useCallback(async(folderName: string) => {
+    if (folderName.trim() === '') {
+      return cancel();
+    }
+
     try {
       // TODO: do not use any type
-      await updateBookmarkFolder(folderId, folderName, parent as any, childFolder);
+      await updateBookmarkFolder(folderId, folderName.trim(), parent as any, childFolder);
       bookmarkFolderTreeMutation();
       setIsRenameAction(false);
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolderTreeMutation, childFolder, folderId, parent]);
+  }, [bookmarkFolderTreeMutation, cancel, childFolder, folderId, parent]);
 
   // Create new folder / subfolder handler
-  const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
+  const create = useCallback(async(folderName: string) => {
+    if (folderName.trim() === '') {
+      return cancel();
+    }
+
     try {
-      await addNewFolder(folderName, targetFolder);
+      await addNewFolder(folderName.trim(), targetFolder);
       setIsOpen(true);
       setIsCreateAction(false);
       bookmarkFolderTreeMutation();
@@ -83,7 +97,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolderTreeMutation, targetFolder]);
+  }, [bookmarkFolderTreeMutation, cancel, targetFolder]);
 
   const onClickPlusButton = useCallback(async(e) => {
     e.stopPropagation();
@@ -244,11 +258,13 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
             <FolderIcon isOpen={isOpen} />
           </div>
           {isRenameAction ? (
-            <BookmarkFolderNameInput
-              onClickOutside={() => setIsRenameAction(false)}
-              onPressEnter={onPressEnterHandlerForRename}
-              value={name}
-            />
+            <div className="flex-fill">
+              <BookmarkFolderNameInput
+                value={name}
+                onSubmit={rename}
+                onCancel={cancel}
+              />
+            </div>
           ) : (
             <>
               <div className="grw-foldertree-title-anchor ps-1">
@@ -288,12 +304,10 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
         </li>
       </DragAndDropWrapper>
       {isCreateAction && (
-        <div className="flex-fill">
-          <BookmarkFolderNameInput
-            onClickOutside={() => setIsCreateAction(false)}
-            onPressEnter={onPressEnterHandlerForCreate}
-          />
-        </div>
+        <BookmarkFolderNameInput
+          onSubmit={create}
+          onCancel={cancel}
+        />
       )}
       {
         renderChildFolder()

+ 53 - 15
apps/app/src/components/Bookmarks/BookmarkFolderNameInput.tsx

@@ -1,30 +1,68 @@
+import type { ChangeEvent } from 'react';
+import { useCallback, useRef, useState } from 'react';
+
+import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
+import type { AutosizeInputProps } from 'react-input-autosize';
+import { debounce } from 'throttle-debounce';
+
+import type { InputValidationResult } from '~/client/util/use-input-validator';
+import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
 
-import { ValidationTarget } from '~/client/util/input-validator';
-import ClosableTextInput from '~/components/Common/ClosableTextInput';
+import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput';
+import type { SubmittableInputProps } from '../Common/SubmittableInput/types';
 
 
-type Props = {
-  onClickOutside: () => void
-  onPressEnter: (folderName: string) => void
-  value?: string
-}
+type Props = Pick<SubmittableInputProps<AutosizeInputProps>, 'value' | 'onSubmit' | 'onCancel'>;
 
 export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
-  const {
-    onClickOutside, onPressEnter, value,
-  } = props;
   const { t } = useTranslation();
 
+  const { value, onSubmit, onCancel } = props;
+
+  const parentRef = useRef<HTMLDivElement>(null);
+  const [parentRect] = useRect(parentRef);
+
+  const [validationResult, setValidationResult] = useState<InputValidationResult>();
+
+
+  const inputValidator = useInputValidator(ValidationTarget.FOLDER);
+
+  const changeHandler = useCallback(async(e: ChangeEvent<HTMLInputElement>) => {
+    const validationResult = inputValidator(e.target.value);
+    setValidationResult(validationResult ?? undefined);
+  }, [inputValidator]);
+  const changeHandlerDebounced = debounce(300, changeHandler);
+
+  const cancelHandler = useCallback(() => {
+    setValidationResult(undefined);
+    onCancel?.();
+  }, [onCancel]);
+
+  const isInvalid = validationResult != null;
+
+  const maxWidth = parentRect != null
+    ? getAdjustedMaxWidthForAutosizeInput(parentRect.width, 'md', validationResult != null ? false : undefined)
+    : undefined;
+
   return (
-    <div className="flex-fill folder-name-input">
-      <ClosableTextInput
+    <div ref={parentRef}>
+      <AutosizeSubmittableInput
         value={value}
+        inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
+        inputStyle={{ maxWidth }}
         placeholder={t('bookmark_folder.input_placeholder')}
-        onClickOutside={onClickOutside}
-        onPressEnter={onPressEnter}
-        validationTarget={ValidationTarget.FOLDER}
+        aria-describedby={isInvalid ? 'bookmark-folder-name-input-feedback' : undefined}
+        autoFocus
+        onChange={changeHandlerDebounced}
+        onSubmit={onSubmit}
+        onCancel={cancelHandler}
       />
+      { isInvalid && (
+        <div id="bookmark-folder-name-input-feedback" className="invalid-feedback d-block my-1">
+          {validationResult.message}
+        </div>
+      ) }
     </div>
   );
 };

+ 15 - 10
apps/app/src/components/Bookmarks/BookmarkItem.tsx

@@ -12,17 +12,16 @@ import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
 
 import { bookmark, unbookmark, unlink } from '~/client/services/page-operation';
 import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
-import { ValidationTarget } from '~/client/util/input-validator';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { BookmarkFolderItems, DragItemDataType } from '~/interfaces/bookmark-info';
 import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import { usePutBackPageModal } from '~/stores/modal';
 import { mutateAllPageInfo, useSWRMUTxCurrentPage, useSWRxPageInfo } from '~/stores/page';
 
-import ClosableTextInput from '../Common/ClosableTextInput';
 import { MenuItemType, PageItemControl } from '../Common/Dropdown/PageItemControl';
 import { PageListItemS } from '../PageList/PageListItemS';
 
+import { BookmarkItemRenameInput } from './BookmarkItemRenameInput';
 import { BookmarkMoveToRootBtn } from './BookmarkMoveToRootBtn';
 import { DragAndDropWrapper } from './DragAndDropWrapper';
 
@@ -86,9 +85,17 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     setRenameInputShown(true);
   }, []);
 
-  const pressEnterForRenameHandler = useCallback(async(inputText: string) => {
+  const cancel = useCallback(() => {
+    setRenameInputShown(false);
+  }, []);
+
+  const rename = useCallback(async(inputText: string) => {
+    if (inputText.trim() === '') {
+      return cancel();
+    }
+
     const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(bookmarkedPage.path ?? ''));
-    const newPagePath = nodePath.resolve(parentPath, inputText);
+    const newPagePath = nodePath.resolve(parentPath, inputText.trim());
     if (newPagePath === bookmarkedPage.path) {
       setRenameInputShown(false);
       return;
@@ -104,7 +111,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       setRenameInputShown(true);
       toastError(err);
     }
-  }, [bookmarkedPage.path, bookmarkedPage._id, bookmarkedPage.revision, bookmarkFolderTreeMutation, mutatePageInfo]);
+  }, [bookmarkedPage.path, bookmarkedPage._id, bookmarkedPage.revision, cancel, bookmarkFolderTreeMutation, mutatePageInfo]);
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
     if (bookmarkedPage._id == null || bookmarkedPage.path == null) {
@@ -155,12 +162,10 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       >
         { isRenameInputShown
           ? (
-            <ClosableTextInput
+            <BookmarkItemRenameInput
               value={nodePath.basename(bookmarkedPage.path ?? '')}
-              placeholder={t('Input page name')}
-              onClickOutside={() => { setRenameInputShown(false) }}
-              onPressEnter={pressEnterForRenameHandler}
-              validationTarget={ValidationTarget.PAGE}
+              onSubmit={rename}
+              onCancel={() => { setRenameInputShown(false) }}
             />
           )
           : <PageListItemS page={bookmarkedPage} pageTitle={pageTitle} isNarrowView />}

+ 68 - 0
apps/app/src/components/Bookmarks/BookmarkItemRenameInput.tsx

@@ -0,0 +1,68 @@
+import type { ChangeEvent } from 'react';
+import { useCallback, useRef, useState } from 'react';
+
+import { useRect } from '@growi/ui/dist/utils';
+import { useTranslation } from 'next-i18next';
+import type { AutosizeInputProps } from 'react-input-autosize';
+import { debounce } from 'throttle-debounce';
+
+import type { InputValidationResult } from '~/client/util/use-input-validator';
+import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
+
+import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput';
+import type { SubmittableInputProps } from '../Common/SubmittableInput/types';
+
+
+type Props = Pick<SubmittableInputProps<AutosizeInputProps>, 'value' | 'onSubmit' | 'onCancel'>;
+
+export const BookmarkItemRenameInput = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { value, onSubmit, onCancel } = props;
+
+  const parentRef = useRef<HTMLDivElement>(null);
+  const [parentRect] = useRect(parentRef);
+
+  const [validationResult, setValidationResult] = useState<InputValidationResult>();
+
+
+  const inputValidator = useInputValidator(ValidationTarget.PAGE);
+
+  const changeHandler = useCallback(async(e: ChangeEvent<HTMLInputElement>) => {
+    const validationResult = inputValidator(e.target.value);
+    setValidationResult(validationResult ?? undefined);
+  }, [inputValidator]);
+  const changeHandlerDebounced = debounce(300, changeHandler);
+
+  const cancelHandler = useCallback(() => {
+    setValidationResult(undefined);
+    onCancel?.();
+  }, [onCancel]);
+
+  const isInvalid = validationResult != null;
+
+  const maxWidth = parentRect != null
+    ? getAdjustedMaxWidthForAutosizeInput(parentRect.width, 'md', validationResult != null ? false : undefined)
+    : undefined;
+
+  return (
+    <div className="flex-fill" ref={parentRef}>
+      <AutosizeSubmittableInput
+        value={value}
+        inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
+        inputStyle={{ maxWidth }}
+        placeholder={t('Input page name')}
+        aria-describedby={isInvalid ? 'bookmark-item-rename-input-feedback' : undefined}
+        autoFocus
+        onChange={changeHandlerDebounced}
+        onSubmit={onSubmit}
+        onCancel={cancelHandler}
+      />
+      { isInvalid && (
+        <div id="bookmark-item-rename-input-feedback" className="invalid-feedback d-block my-1">
+          {validationResult.message}
+        </div>
+      ) }
+    </div>
+  );
+};

+ 0 - 1
apps/app/src/components/Bookmarks/BookmarkMoveToRootBtn.tsx

@@ -13,7 +13,6 @@ export const BookmarkMoveToRootBtn: React.FC<{
     <DropdownItem
       onClick={() => onClickMoveToRootHandler(pageId)}
       className="grw-page-control-dropdown-item"
-      data-testid="add-remove-bookmark-btn"
     >
       <span className="material-symbols-outlined grw-page-control-dropdown-icon">bookmark</span>
       {t('bookmark_folder.move_to_root')}

+ 0 - 151
apps/app/src/components/Common/ClosableTextInput.tsx

@@ -1,151 +0,0 @@
-import type { FC } from 'react';
-import React, {
-  memo, useEffect, useRef, useState,
-} from 'react';
-
-import { useTranslation } from 'next-i18next';
-import AutosizeInput from 'react-input-autosize';
-
-import type { AlertInfo } from '~/client/util/input-validator';
-import { AlertType, inputValidator } from '~/client/util/input-validator';
-
-type ClosableTextInputProps = {
-  value?: string
-  placeholder?: string
-  validationTarget?: string,
-  useAutosizeInput?: boolean
-  inputClassName?: string,
-  onPressEnter?(inputText: string | null): void
-  onPressEscape?: () => void
-  onClickOutside?(): void
-  onChange?(inputText: string): void
-}
-
-const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextInputProps) => {
-  const { t } = useTranslation();
-  const { validationTarget } = props;
-
-  const inputRef = useRef<HTMLInputElement>(null);
-  const [inputText, setInputText] = useState(props.value);
-  const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
-  const [isAbleToShowAlert, setIsAbleToShowAlert] = useState(false);
-  const [isComposing, setComposing] = useState(false);
-
-
-  const createValidation = async(inputText: string) => {
-    const alertInfo = await inputValidator(inputText, validationTarget);
-    if (alertInfo && alertInfo.message != null && alertInfo.target != null) {
-      alertInfo.message = t(alertInfo.message, { target: t(alertInfo.target) });
-    }
-    setAlertInfo(alertInfo);
-  };
-
-  const onChangeHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
-    const inputText = e.target.value;
-    createValidation(inputText);
-    setInputText(inputText);
-    setIsAbleToShowAlert(true);
-
-    props.onChange?.(inputText);
-  };
-
-  const onFocusHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
-    const inputText = e.target.value;
-    await createValidation(inputText);
-  };
-
-  const onPressEnter = () => {
-    if (props.onPressEnter != null) {
-      const text = inputText != null ? inputText.trim() : null;
-      if (currentAlertInfo == null) {
-        props.onPressEnter(text);
-      }
-    }
-  };
-
-  const onKeyDownHandler = (e) => {
-    switch (e.key) {
-      case 'Enter':
-        // Do nothing when composing
-        if (isComposing) {
-          return;
-        }
-        onPressEnter();
-        break;
-      case 'Escape':
-        if (isComposing) {
-          return;
-        }
-        props.onPressEscape?.();
-        break;
-      default:
-        break;
-    }
-  };
-
-  /*
-   * Hide when click outside the ref
-   */
-  const onBlurHandler = () => {
-    if (props.onClickOutside == null) {
-      return;
-    }
-
-    props.onClickOutside();
-  };
-
-  // didMount
-  useEffect(() => {
-    // autoFocus
-    if (inputRef?.current == null) {
-      return;
-    }
-    inputRef.current.focus();
-  });
-
-
-  const AlertInfo = () => {
-    if (currentAlertInfo == null) {
-      return <></>;
-    }
-
-    const alertType = currentAlertInfo.type != null ? currentAlertInfo.type : AlertType.ERROR;
-    const alertMessage = currentAlertInfo.message != null ? currentAlertInfo.message : 'Invalid value';
-    const alertTextStyle = alertType === AlertType.ERROR ? 'text-danger' : 'text-warning';
-    const translation = alertType === AlertType.ERROR ? 'Error' : 'Warning';
-    return (
-      <p className={`${alertTextStyle} text-center mt-1`}>{t(translation)}: {alertMessage}</p>
-    );
-  };
-
-  const inputProps = {
-    'data-testid': 'closable-text-input',
-    value: inputText || '',
-    ref: inputRef,
-    type: 'text',
-    placeholder: props.placeholder,
-    name: 'input',
-    onFocus: onFocusHandler,
-    onChange: onChangeHandler,
-    onKeyDown: onKeyDownHandler,
-    onCompositionStart: () => setComposing(true),
-    onCompositionEnd: () => setComposing(false),
-    onBlur: onBlurHandler,
-  };
-
-  const inputClassName = `form-control ${props.inputClassName ?? ''}`;
-
-  return (
-    <div>
-      { props.useAutosizeInput
-        ? <AutosizeInput inputClassName={inputClassName} {...inputProps} />
-        : <input className={inputClassName} {...inputProps} />
-      }
-      {isAbleToShowAlert && <AlertInfo />}
-    </div>
-  );
-});
-
-ClosableTextInput.displayName = 'ClosableTextInput';
-
-export default ClosableTextInput;

+ 27 - 6
apps/app/src/components/Common/CopyDropdown/CopyDropdown.jsx

@@ -16,10 +16,12 @@ import styles from './CopyDropdown.module.scss';
 const { encodeSpaces } = pagePathUtils;
 
 /* eslint-disable react/prop-types */
-const DropdownItemContents = ({ title, contents }) => (
+const DropdownItemContents = ({
+  title, contents, className, style,
+}) => (
   <>
     <div className="h6 mt-1 mb-2"><strong>{title}</strong></div>
-    <div className="card custom-card mb-1 p-2">{contents}</div>
+    <div className={`card custom-card mb-1 p-2 ${className}`} style={style}>{contents}</div>
   </>
 );
 /* eslint-enable react/prop-types */
@@ -110,7 +112,12 @@ export const CopyDropdown = (props) => {
 
   return (
     <>
-      <Dropdown className={`${styles['grw-copy-dropdown']} grw-copy-dropdown d-print-none`} isOpen={dropdownOpen} size="sm" toggle={toggleDropdown}>
+      <Dropdown
+        className={`${styles['grw-copy-dropdown']} grw-copy-dropdown d-print-none`}
+        isOpen={dropdownOpen}
+        size="sm"
+        toggle={toggleDropdown}
+      >
         <DropdownToggle
           caret={isShareLinkMode}
           className={`btn-copy ${dropdownToggleClassName}`}
@@ -144,7 +151,11 @@ export const CopyDropdown = (props) => {
           {/* Page path */}
           <CopyToClipboard text={pagePathWithParams} onCopy={showToolTip}>
             <DropdownItem className="px-3">
-              <DropdownItemContents title={t('copy_to_clipboard.Page path')} contents={pagePathWithParams} />
+              <DropdownItemContents
+                title={t('copy_to_clipboard.Page path')}
+                contents={pagePathWithParams}
+                className="text-truncate d-block"
+              />
             </DropdownItem>
           </CopyToClipboard>
 
@@ -153,7 +164,11 @@ export const CopyDropdown = (props) => {
           {/* Page path URL */}
           <CopyToClipboard text={pagePathUrl} onCopy={showToolTip}>
             <DropdownItem className="px-3">
-              <DropdownItemContents title={t('copy_to_clipboard.Page URL')} contents={pagePathUrl} />
+              <DropdownItemContents
+                title={t('copy_to_clipboard.Page URL')}
+                contents={pagePathUrl}
+                className="text-truncate d-block"
+              />
             </DropdownItem>
           </CopyToClipboard>
           <DropdownItem divider className="my-0"></DropdownItem>
@@ -162,7 +177,11 @@ export const CopyDropdown = (props) => {
           { pageId && (
             <CopyToClipboard text={permalink} onCopy={showToolTip}>
               <DropdownItem className="px-3">
-                <DropdownItemContents title={t('copy_to_clipboard.Permanent link')} contents={permalink} />
+                <DropdownItemContents
+                  title={t('copy_to_clipboard.Permanent link')}
+                  contents={permalink}
+                  className="text-truncate d-block"
+                />
               </DropdownItem>
             </CopyToClipboard>
           )}
@@ -176,6 +195,8 @@ export const CopyDropdown = (props) => {
                 <DropdownItemContents
                   title={t('copy_to_clipboard.Page path and permanent link')}
                   contents={<>{pagePathWithParams}<br />{permalink}</>}
+                  className="text-truncate"
+                  style={{ direction: 'rtl' }}
                 />
               </DropdownItem>
             </CopyToClipboard>

+ 5 - 0
apps/app/src/components/Common/CopyDropdown/CopyDropdown.module.scss

@@ -9,6 +9,11 @@
 
   .dropdown-menu {
     min-width: 310px;
+    max-width: 375px;
+
+    @include bs.media-breakpoint-up(md) {
+      max-width: 600px;
+    }
 
     .dropdown-header {
       margin-bottom: 0.5em;

+ 1 - 1
apps/app/src/components/Common/Dropdown/PageItemControl.spec.tsx

@@ -28,7 +28,7 @@ describe('PageItemControl.tsx', () => {
     render(<PageItemControl {...props} />);
 
     // when
-    const openPageMoveRenameModalButton = screen.getByTestId('open-page-move-rename-modal-btn');
+    const openPageMoveRenameModalButton = screen.getByTestId('rename-page-btn');
     await waitFor(() => userEvent.click(openPageMoveRenameModalButton, { pointerEventsCheck: PointerEventsCheckLevel.Never }));
 
     // then

+ 2 - 2
apps/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -169,7 +169,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           <DropdownItem
             onClick={bookmarkItemClickedHandler}
             className="grw-page-control-dropdown-item"
-            data-testid="add-remove-bookmark-btn"
+            data-testid={pageInfo.isBookmarked ? 'remove-bookmark-btn' : 'add-bookmark-btn'}
           >
             <span className="material-symbols-outlined grw-page-control-dropdown-icon">bookmark</span>
             { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
@@ -180,7 +180,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser && pageInfo.isMovable && (
           <DropdownItem
             onClick={renameItemClickedHandler}
-            data-testid="open-page-move-rename-modal-btn"
+            data-testid="rename-page-btn"
             className="grw-page-control-dropdown-item"
           >
             <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">redo</span>

+ 3 - 4
apps/app/src/components/Common/ImageCropModal.tsx

@@ -1,6 +1,5 @@
-import React, {
-  FC, useCallback, useEffect, useState,
-} from 'react';
+import type { FC } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
 
 import canvasToBlob from 'async-canvas-to-blob';
 import { useTranslation } from 'react-i18next';
@@ -137,7 +136,7 @@ const ImageCropModal: FC<Props> = (props: Props) => {
 
   return (
     <Modal isOpen={isShow} toggle={onModalCloseHandler}>
-      <ModalHeader tag="h4" toggle={onModalCloseHandler} className="bg-info text-light">
+      <ModalHeader tag="h4" toggle={onModalCloseHandler} className="text-info">
         {t('crop_image_modal.image_crop')}
       </ModalHeader>
       <ModalBody className="my-4">

+ 30 - 0
apps/app/src/components/Common/SubmittableInput/AutosizeSubmittableInput.tsx

@@ -0,0 +1,30 @@
+import type {
+  ReactElement,
+} from 'react';
+
+import type { AutosizeInputProps } from 'react-input-autosize';
+import AutosizeInput from 'react-input-autosize';
+
+import type { SubmittableInputProps } from './types';
+import { useSubmittable } from './use-submittable';
+
+
+export const getAdjustedMaxWidthForAutosizeInput = (parentMaxWidth: number, size: 'sm' | 'md' | 'lg' = 'md', isValid?: boolean): number => {
+  // eslint-disable-next-line no-nested-ternary
+  const bsFormPaddingSize = size === 'sm' ? 8 : size === 'md' ? 12 : 16; // by bootstrap form
+  // eslint-disable-next-line no-nested-ternary
+  const bsValidationIconSize = size === 'sm' ? 25 : size === 'md' ? 24 : 26; // by bootstrap form validation
+
+  return parentMaxWidth
+      - bsFormPaddingSize * 2 // minus the padding (12px * 2) because AutosizeInput has "box-sizing: content-box;"
+      - (isValid === false ? bsValidationIconSize : 0); // minus the width for the exclamation icon
+};
+
+export const AutosizeSubmittableInput = (props: SubmittableInputProps<AutosizeInputProps>): ReactElement<AutosizeInput> => {
+
+  const submittableProps = useSubmittable(props);
+
+  return (
+    <AutosizeInput {...submittableProps} data-testid="autosize-submittable-input" />
+  );
+};

+ 23 - 0
apps/app/src/components/Common/SubmittableInput/SubmittableInput.tsx

@@ -0,0 +1,23 @@
+import type {
+  ReactElement,
+} from 'react';
+
+import type { SubmittableInputProps } from './types';
+import { useSubmittable } from './use-submittable';
+
+
+export const SubmittableInput = (props: SubmittableInputProps): ReactElement<HTMLInputElement> => {
+  // // autoFocus
+  // useEffect(() => {
+  //   if (inputRef?.current == null) {
+  //     return;
+  //   }
+  //   inputRef.current.focus();
+  // });
+
+  const submittableProps = useSubmittable(props);
+
+  return (
+    <input {...submittableProps} />
+  );
+};

+ 2 - 0
apps/app/src/components/Common/SubmittableInput/index.ts

@@ -0,0 +1,2 @@
+export * from './SubmittableInput';
+export * from './AutosizeSubmittableInput';

+ 7 - 0
apps/app/src/components/Common/SubmittableInput/types.d.ts

@@ -0,0 +1,7 @@
+export type SubmittableInputProps<T extends InputHTMLAttributes<HTMLInputElement> = InputHTMLAttributes<HTMLInputElement>> =
+  Omit<InputHTMLAttributes<T>, 'value' | 'onKeyDown' | 'onSubmit'>
+  & {
+    value?: string,
+    onSubmit?: (inputText: string) => void,
+    onCancel?: () => void,
+  }

+ 80 - 0
apps/app/src/components/Common/SubmittableInput/use-submittable.ts

@@ -0,0 +1,80 @@
+import type {
+  CompositionEvent,
+} from 'react';
+import type React from 'react';
+import {
+  useCallback, useState,
+} from 'react';
+
+import type { SubmittableInputProps } from './types';
+
+export const useSubmittable = (props: SubmittableInputProps): Partial<React.InputHTMLAttributes<HTMLInputElement>> => {
+
+  const {
+    value,
+    onChange, onBlur,
+    onCompositionStart, onCompositionEnd,
+    onSubmit, onCancel,
+  } = props;
+
+  const [inputText, setInputText] = useState(value ?? '');
+  const [isComposing, setComposing] = useState(false);
+
+  const changeHandler = useCallback(async(e: React.ChangeEvent<HTMLInputElement>) => {
+    const inputText = e.target.value;
+    setInputText(inputText);
+
+    onChange?.(e);
+  }, [onChange]);
+
+  const keyDownHandler = useCallback((e) => {
+    switch (e.key) {
+      case 'Enter':
+        // Do nothing when composing
+        if (isComposing) {
+          return;
+        }
+        onSubmit?.(inputText.trim());
+        break;
+      case 'Escape':
+        if (isComposing) {
+          return;
+        }
+        onCancel?.();
+        break;
+    }
+  }, [inputText, isComposing, onCancel, onSubmit]);
+
+  const blurHandler = useCallback((e) => {
+    // submit on blur
+    onSubmit?.(inputText.trim());
+    onBlur?.(e);
+  }, [inputText, onSubmit, onBlur]);
+
+  const compositionStartHandler = useCallback((e: CompositionEvent<HTMLInputElement>) => {
+    setComposing(true);
+    onCompositionStart?.(e);
+  }, [onCompositionStart]);
+
+  const compositionEndHandler = useCallback((e: CompositionEvent<HTMLInputElement>) => {
+    setComposing(false);
+    onCompositionEnd?.(e);
+  }, [onCompositionEnd]);
+
+  const {
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    value: _value, onSubmit: _onSubmit, onCancel: _onCancel,
+    ...cleanedProps
+  } = props;
+
+  return {
+    ...cleanedProps,
+    value: inputText,
+    onChange: changeHandler,
+    onKeyDown: keyDownHandler,
+    onBlur: blurHandler,
+    onCompositionStart: compositionStartHandler,
+    onCompositionEnd: compositionEndHandler,
+  };
+
+};

+ 1 - 1
apps/app/src/components/CompleteUserRegistrationForm.tsx

@@ -182,7 +182,7 @@ const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
 
               <div className="input-group justify-content-center mt-4">
                 <button
-                  type="button"
+                  type="submit"
                   disabled={forceDisableForm || disableForm}
                   className="btn btn-secondary btn-register col-6 mx-auto d-flex"
                 >

+ 1 - 1
apps/app/src/components/ContentLinkButtons.tsx

@@ -40,7 +40,7 @@ RecentlyCreatedLinkButton.displayName = 'RecentlyCreatedLinkButton';
 
 
 export type ContentLinkButtonsProps = {
-  author: IUserHasId | null,
+  author?: IUserHasId,
 }
 
 export const ContentLinkButtons = (props: ContentLinkButtonsProps): JSX.Element => {

+ 1 - 1
apps/app/src/components/CreateTemplateModal.tsx

@@ -86,7 +86,7 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
 
   return (
     <Modal isOpen={isOpen} toggle={onClose} data-testid="page-template-modal">
-      <ModalHeader tag="h4" toggle={onClose} className="bg-primary text-light">
+      <ModalHeader tag="h4" toggle={onClose}>
         {t('template.modal_label.Create/Edit Template Page')}
       </ModalHeader>
       <ModalBody>

+ 1 - 1
apps/app/src/components/DeleteBookmarkFolderModal.tsx

@@ -42,7 +42,7 @@ const DeleteBookmarkFolderModal: FC = () => {
 
   return (
     <Modal size="md" isOpen={isOpened} toggle={closeBookmarkFolderDeleteModal} data-testid="page-delete-modal" className="grw-create-page">
-      <ModalHeader tag="h4" toggle={closeBookmarkFolderDeleteModal} className="bg-danger text-light">
+      <ModalHeader tag="h4" toggle={closeBookmarkFolderDeleteModal} className="text-danger">
         <span className="material-symbols-outlined">delete</span>
         {t('bookmark_folder.delete_modal.modal_header_label')}
       </ModalHeader>

+ 3 - 2
apps/app/src/components/EmptyTrashModal.tsx

@@ -1,5 +1,6 @@
+import type { FC } from 'react';
 import React, {
-  useState, FC,
+  useState,
 } from 'react';
 
 import { useTranslation } from 'next-i18next';
@@ -60,7 +61,7 @@ const EmptyTrashModal: FC = () => {
 
   return (
     <Modal size="lg" isOpen={isOpened} toggle={closeEmptyTrashModal} data-testid="page-delete-modal">
-      <ModalHeader tag="h4" toggle={closeEmptyTrashModal} className="bg-danger text-light">
+      <ModalHeader tag="h4" toggle={closeEmptyTrashModal} className="text-danger">
         <span className="material-symbols-outlined">delete_forever</span>
         {t('modal_empty.empty_the_trash')}
       </ModalHeader>

+ 0 - 153
apps/app/src/components/ItemsTree/ItemsTree.module.scss

@@ -1,153 +0,0 @@
-@use '~/styles/mixins' as *;
-$grw-sidebar-content-header-height: 58px;
-$grw-sidebar-content-footer-height: 50px;
-$grw-pagetree-item-padding-left: 10px;
-$grw-pagetree-item-container-height: 40px;
-
-.grw-pagetree {
-
-  .grw-pagetree-item-skeleton-text {
-    @include grw-skeleton-text($font-size:16px, $line-height:$grw-pagetree-item-container-height);
-    padding-left: 12px;
-  }
-
-  .grw-pagetree-item-skeleton-text-child {
-    @extend .grw-pagetree-item-skeleton-text;
-    padding-left: 12px + $grw-pagetree-item-padding-left;
-  }
-
-  :global {
-
-    .list-group-item {
-      .grw-visible-on-hover {
-        display: none;
-      }
-
-      &:hover {
-        .grw-visible-on-hover {
-          display: block;
-        }
-
-        .grw-count-badge {
-          display: none;
-        }
-      }
-
-      .grw-pagetree-triangle-btn {
-        border: 0;
-        transition: all 0.2s ease-out;
-        transform: rotate(0deg);
-
-        &.grw-pagetree-open {
-          transform: rotate(90deg);
-        }
-      }
-
-      .grw-pagetree-title-anchor {
-        width: 100%;
-        overflow: hidden;
-        text-decoration: none;
-      }
-
-      .grw-pagetree-count-wrapper {
-        display: inline-block;
-
-        &:hover {
-          display: none;
-        }
-      }
-    }
-
-    .grw-pagetree-item-container {
-      .grw-triangle-container {
-        min-width: 35px;
-        height: $grw-pagetree-item-container-height;
-      }
-    }
-  }
-  &:global{
-    // To realize a hierarchical structure, set multiplied padding-left to each pagetree-item
-    > .grw-pagetree-item-container {
-      > .list-group-item {
-        padding-left: 0;
-      }
-      > .grw-pagetree-item-children {
-        > .grw-pagetree-item-container {
-          > .list-group-item {
-            padding-left: $grw-pagetree-item-padding-left;
-          }
-          > .grw-pagetree-item-children {
-            > .grw-pagetree-item-container {
-              > .list-group-item {
-                padding-left: $grw-pagetree-item-padding-left * 2;
-              }
-              > .grw-pagetree-item-children {
-                > .grw-pagetree-item-container {
-                  > .list-group-item {
-                    padding-left: $grw-pagetree-item-padding-left * 3;
-                  }
-                  > .grw-pagetree-item-children {
-                    > .grw-pagetree-item-container {
-                      > .list-group-item {
-                        padding-left: $grw-pagetree-item-padding-left * 4;
-                      }
-                      > .grw-pagetree-item-children {
-                        > .grw-pagetree-item-container {
-                          > .list-group-item {
-                            padding-left: $grw-pagetree-item-padding-left * 5;
-                          }
-                          > .grw-pagetree-item-children {
-                            > .grw-pagetree-item-container {
-                              > .list-group-item {
-                                padding-left: $grw-pagetree-item-padding-left * 6;
-                              }
-                              > .grw-pagetree-item-children {
-                                > .grw-pagetree-item-container {
-                                  > .list-group-item {
-                                    padding-left: $grw-pagetree-item-padding-left * 7;
-                                  }
-                                  > .grw-pagetree-item-children {
-                                    > .grw-pagetree-item-container {
-                                      > .list-group-item {
-                                        padding-left: $grw-pagetree-item-padding-left * 8;
-                                      }
-                                      > .grw-pagetree-item-children {
-                                        > .grw-pagetree-item-container {
-                                          > .list-group-item {
-                                            padding-left: $grw-pagetree-item-padding-left * 9;
-                                          }
-                                          .grw-pagetree-item-children {
-                                            > .grw-pagetree-item-container {
-                                              > .list-group-item {
-                                                padding-left: $grw-pagetree-item-padding-left * 10;
-                                              }
-                                            }
-                                          }
-                                        }
-                                      }
-                                    }
-                                  }
-                                }
-                              }
-                            }
-                          }
-                        }
-                      }
-                    }
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-    }
-  }
-}
-
-
-.grw-pagetree :global {
-  .grw-pagetree-triangle-btn {
-    --btn-color: var(--bs-tertiary-color);
-  }
-}

+ 4 - 57
apps/app/src/components/ItemsTree/ItemsTree.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useEffect, useRef, useState, useMemo, useCallback,
+  useEffect, useMemo, useCallback,
 } from 'react';
 
 import path from 'path';
@@ -8,7 +8,6 @@ import type { Nullable, IPageHasId, IPageToDeleteWithMeta } from '@growi/core';
 import { useGlobalSocket } from '@growi/core/dist/swr';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
-import { debounce } from 'throttle-debounce';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { IPageForItem } from '~/interfaces/page';
@@ -23,7 +22,7 @@ import {
   useSWRxPageAncestorsChildren, useSWRxRootPage, mutatePageTree, mutatePageList,
 } from '~/stores/page-listing';
 import { mutateSearching } from '~/stores/search';
-import { usePageTreeDescCountMap, useSidebarScrollerRef } from '~/stores/ui';
+import { usePageTreeDescCountMap } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
 import { ItemNode, type TreeItemProps } from '../TreeItem';
@@ -32,6 +31,7 @@ import ItemsTreeContentSkeleton from './ItemsTreeContentSkeleton';
 
 import styles from './ItemsTree.module.scss';
 
+const moduleClass = styles['items-tree'] ?? '';
 
 const logger = loggerFactory('growi:cli:ItemsTree');
 
@@ -115,7 +115,6 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openDeleteModal } = usePageDeleteModal();
-  const { data: sidebarScrollerRef } = useSidebarScrollerRef();
 
   const { data: socket } = useGlobalSocket();
   const { data: ptDescCountMap, update: updatePtDescCountMap } = usePageTreeDescCountMap();
@@ -123,9 +122,6 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   // for mutation
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
 
-  const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false);
-
-  const rootElemRef = useRef(null);
 
   const renderingCondition = useMemo(() => {
     return {
@@ -200,55 +196,6 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
   }, [currentPagePath, mutateCurrentPage, openDeleteModal, router, t]);
 
-  // ***************************  Scroll on init ***************************
-  const scrollOnInit = useCallback(() => {
-    const scrollTargetElement = document.getElementById('grw-pagetree-current-page-item');
-
-    if (sidebarScrollerRef?.current == null || scrollTargetElement == null) {
-      return;
-    }
-
-    logger.debug('scrollOnInit has invoked');
-
-    const scrollElement = sidebarScrollerRef.current.getScrollElement();
-
-    // NOTE: could not use scrollIntoView
-    //  https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move
-
-    // calculate the center point
-    const scrollTop = scrollTargetElement.offsetTop - scrollElement.getBoundingClientRect().height / 2;
-    scrollElement.scrollTo({ top: scrollTop });
-
-    setIsInitialScrollCompleted(true);
-  }, [sidebarScrollerRef]);
-
-  const scrollOnInitDebounced = useMemo(() => debounce(500, scrollOnInit), [scrollOnInit]);
-
-  useEffect(() => {
-    if (!isSecondStageRenderingCondition(renderingCondition) || isInitialScrollCompleted) {
-      return;
-    }
-
-    const rootElement = rootElemRef.current as HTMLElement | null;
-    if (rootElement == null) {
-      return;
-    }
-
-    const observerCallback = (mutationRecords: MutationRecord[]) => {
-      mutationRecords.forEach(() => scrollOnInitDebounced());
-    };
-
-    const observer = new MutationObserver(observerCallback);
-    observer.observe(rootElement, { childList: true, subtree: true });
-
-    // first call for the situation that all rendering is complete at this point
-    scrollOnInitDebounced();
-
-    return () => {
-      observer.disconnect();
-    };
-  }, [isInitialScrollCompleted, renderingCondition, scrollOnInitDebounced]);
-  // *******************************  end  *******************************
 
   if (error1 != null || error2 != null) {
     // TODO: improve message
@@ -275,7 +222,7 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
 
   if (initialItemNode != null) {
     return (
-      <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group py-4`} ref={rootElemRef}>
+      <ul className={`${moduleClass} list-group`}>
         <CustomTreeItem
           key={initialItemNode.page.path}
           targetPathOrId={targetPathOrId}

+ 11 - 0
apps/app/src/components/ItemsTree/ItemsTreeContentSkeleton.module.scss

@@ -0,0 +1,11 @@
+@use '~/styles/mixins';
+
+.text-skeleton-level1 {
+  @include mixins.grw-skeleton-text($font-size:16px, $line-height: 40px);
+  padding-left: 12px;
+}
+
+.text-skeleton-level2 {
+  @extend .text-skeleton-level1;
+  padding-left: 12px + 10px * 2;
+}

+ 5 - 7
apps/app/src/components/ItemsTree/ItemsTreeContentSkeleton.tsx

@@ -1,16 +1,14 @@
-import React from 'react';
-
 import { Skeleton } from '~/components/Skeleton';
 
-import styles from './ItemsTree.module.scss';
+import styles from './ItemsTreeContentSkeleton.module.scss';
 
 const ItemsTreeContentSkeleton = (): JSX.Element => {
 
   return (
-    <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group py-3`}>
-      <Skeleton additionalClass={`${styles['grw-pagetree-item-skeleton-text']} pe-3`} />
-      <Skeleton additionalClass={`${styles['grw-pagetree-item-skeleton-text-child']} pe-3`} />
-      <Skeleton additionalClass={`${styles['grw-pagetree-item-skeleton-text-child']} pe-3`} />
+    <ul className="list-group py-3">
+      <Skeleton additionalClass={`${styles['text-skeleton-level1']} pe-3`} />
+      <Skeleton additionalClass={`${styles['text-skeleton-level2']} pe-3`} />
+      <Skeleton additionalClass={`${styles['text-skeleton-level2']} pe-3`} />
     </ul>
   );
 };

+ 0 - 51
apps/app/src/components/Layout/Admin.module.scss

@@ -226,49 +226,6 @@ $slack-work-space-name-card-border: #efc1f6;
     }
   }
 
-  #layoutOptions {
-    .customize-layout-card {
-      border: 4px solid $border-color;
-    }
-  }
-
-  // theme selector
-  #themeOptions {
-    // layout
-    .theme-option-container {
-      min-width: 100px;
-      a {
-        padding: 3px;
-        margin-right: 10px;
-        margin-bottom: 10px;
-
-        svg {
-          display: block;
-        }
-      }
-    }
-
-    &.disabled {
-      cursor: not-allowed;
-      opacity: 0.5;
-    }
-
-    // style
-    .theme-option-container a {
-      background-color: $gray-100;
-      border: 1px solid $border-color;
-    }
-    .theme-option-name, .theme-option-badge {
-      opacity: 0.3;
-    }
-    // style (active)
-    .theme-option-container.active {
-      .theme-option-name, .theme-option-badge {
-        opacity: 1;
-      }
-    }
-  }
-
   .settings-table {
     table-layout: fixed;
 
@@ -285,14 +242,6 @@ $slack-work-space-name-card-border: #efc1f6;
     }
   }
 
-  .admin-navigation {
-    & > a + a {
-      margin-top: 2px;
-    }
-    &.sticky-top {
-      top: 30px;
-    }
-  }
 }
 
 

+ 3 - 2
apps/app/src/components/Layout/BasicLayout.tsx

@@ -1,4 +1,5 @@
-import React, { ReactNode } from 'react';
+import type { ReactNode } from 'react';
+import React from 'react';
 
 import dynamic from 'next/dynamic';
 import { DndProvider } from 'react-dnd';
@@ -42,7 +43,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
             <Sidebar />
           </div>
 
-          <div className="d-flex flex-grow-1 flex-column z-1">{/* neccessary for nested {children} make expanded */}
+          <div className="d-flex flex-grow-1 flex-column mw-0 z-1">{/* neccessary for nested {children} make expanded */}
             <AlertSiteUrlUndefined />
             {children}
           </div>

+ 1 - 1
apps/app/src/components/Me/AssociateModal.tsx

@@ -56,7 +56,7 @@ const AssociateModal = (props: Props): JSX.Element => {
 
   return (
     <Modal isOpen={isOpen} toggle={closeModalHandler} size="lg" data-testid="grw-associate-modal">
-      <ModalHeader className="bg-primary text-light" toggle={onClose}>
+      <ModalHeader toggle={onClose}>
         { t('admin:user_management.create_external_account') }
       </ModalHeader>
       <ModalBody>

+ 1 - 1
apps/app/src/components/Me/DisassociateModal.tsx

@@ -45,7 +45,7 @@ const DisassociateModal = (props: Props): JSX.Element => {
 
   return (
     <Modal isOpen={props.isOpen} toggle={props.onClose}>
-      <ModalHeader className="bg-info text-light" toggle={props.onClose}>
+      <ModalHeader className="text-info" toggle={props.onClose}>
         {t('personal_settings.disassociate_external_account')}
       </ModalHeader>
       <ModalBody>

+ 1 - 1
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -309,7 +309,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
           <nav
             className={`${styles['grw-contextual-sub-navigation']}
-              d-flex align-items-center justify-content-end px-2 px-sm-3 px-md-4 py-1 gap-2 gap-md-4 d-print-none
+              d-flex align-items-center justify-content-end pe-2 pe-sm-3 pe-md-4 py-1 gap-2 gap-md-4 d-print-none
             `}
             data-testid="grw-contextual-sub-nav"
             id="grw-contextual-sub-nav"

+ 14 - 2
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -1,14 +1,14 @@
-import React, { type ReactNode, useCallback } from 'react';
+import React, { type ReactNode, useCallback, useMemo } from 'react';
 
 import { Origin } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
-
 import { useCreatePageAndTransit } from '~/client/services/create-page';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
 import { useIsNotFound } from '~/stores/page';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
+import { useCurrentPageYjsData } from '~/stores/yjs';
 
 import { shouldCreateWipPage } from '../../utils/should-create-wip-page';
 
@@ -66,6 +66,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
   const { data: isNotFound } = useIsNotFound();
   const { mutate: mutateEditorMode } = useEditorMode();
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
+  const { data: currentPageYjsData } = useCurrentPageYjsData();
 
   const { isCreating, createAndTransit } = useCreatePageAndTransit();
 
@@ -92,6 +93,16 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
 
   const _isBtnDisabled = isCreating || isBtnDisabled;
 
+  const circleColor = useMemo(() => {
+    if (currentPageYjsData?.awarenessStateSize != null && currentPageYjsData.awarenessStateSize > 0) {
+      return 'bg-primary';
+    }
+
+    if (currentPageYjsData?.hasRevisionBodyDiff != null && currentPageYjsData.hasRevisionBodyDiff) {
+      return 'bg-secondary';
+    }
+  }, [currentPageYjsData]);
+
   return (
     <>
       <div
@@ -118,6 +129,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
             onClick={editButtonClickedHandler}
           >
             <span className="material-symbols-outlined me-1 fs-5">edit_square</span>{t('Edit')}
+            { circleColor != null && <span className={`position-absolute top-0 start-100 translate-middle p-1 rounded-circle ${circleColor}`} />}
           </PageEditorModeButton>
         )}
       </div>

+ 2 - 0
apps/app/src/components/Page/DisplaySwitcher.tsx

@@ -4,6 +4,7 @@ import dynamic from 'next/dynamic';
 
 import { useHashChangedEffect } from '~/client/services/side-effects/hash-changed';
 import { usePageUpdatedEffect } from '~/client/services/side-effects/page-updated';
+import { useCurrentPageYjsDataEffect } from '~/client/services/side-effects/yjs';
 import { useIsEditable } from '~/stores/context';
 import { useIsLatestRevision } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
@@ -26,6 +27,7 @@ export const DisplaySwitcher = (props: Props): JSX.Element => {
 
   usePageUpdatedEffect();
   useHashChangedEffect();
+  useCurrentPageYjsDataEffect();
 
   return (
     <>

+ 2 - 2
apps/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -235,7 +235,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   return (
     <>
       <Modal size="lg" isOpen={isOpen} toggle={close}>
-        <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+        <ModalHeader tag="h4" toggle={close}>
           { t('fix_page_grant.modal.title') }
         </ModalHeader>
         {renderModalBodyAndFooter()}
@@ -245,7 +245,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
           isOpen={isGroupSelectModalShown}
           toggle={() => setIsGroupSelectModalShown(false)}
         >
-          <ModalHeader tag="h4" toggle={() => setIsGroupSelectModalShown(false)} className="bg-purple text-light">
+          <ModalHeader tag="h4" toggle={() => setIsGroupSelectModalShown(false)}>
             {t('user_group.select_group')}
           </ModalHeader>
           <ModalBody>

+ 2 - 2
apps/app/src/components/PageAttachment/DeleteAttachmentModal.tsx

@@ -76,7 +76,7 @@ export const DeleteAttachmentModal: React.FC = () => {
           <span className="material-symbols-outlined">{iconByFormat(attachment.fileFormat)}</span> {attachment.originalName}
         </p>
         <p>
-          uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator as IUser}></Username>
+          uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator}></Username>
         </p>
         {content}
       </div>
@@ -101,7 +101,7 @@ export const DeleteAttachmentModal: React.FC = () => {
       aria-labelledby="contained-modal-title-lg"
       fade={false}
     >
-      <ModalHeader tag="h4" toggle={toggleHandler} className="bg-danger text-light">
+      <ModalHeader tag="h4" toggle={toggleHandler} className="text-danger">
         <span id="contained-modal-title-lg">{t('delete_attachment_modal.confirm_delete_attachment')}</span>
       </ModalHeader>
       <ModalBody>

+ 2 - 2
apps/app/src/components/PageAuthorInfo/PageAuthorInfo.tsx

@@ -33,12 +33,12 @@ export const PageAuthorInfo = memo((): JSX.Element => {
     <ul className={`grw-page-author-info ${styles['grw-page-author-info']} text-nowrap border-start d-none d-lg-block d-edit-none py-2 ps-4 mb-0 ms-3`}>
       <li className="pb-1">
         {currentPage != null && (
-          <AuthorInfo user={currentPage.creator as IUser} date={currentPage.createdAt} mode="create" locate="subnav" />
+          <AuthorInfo user={currentPage.creator} date={currentPage.createdAt} mode="create" locate="subnav" />
         )}
       </li>
       <li className="mt-1 pt-1 border-top">
         {currentPage != null && (
-          <AuthorInfo user={currentPage.lastUpdateUser as IUser} date={currentPage.updatedAt} mode="update" locate="subnav" />
+          <AuthorInfo user={currentPage.lastUpdateUser} date={currentPage.updatedAt} mode="update" locate="subnav" />
         )}
       </li>
     </ul>

+ 1 - 1
apps/app/src/components/PageComment/DeleteCommentModal.tsx

@@ -85,7 +85,7 @@ export const DeleteCommentModal = (props: DeleteCommentModalProps): JSX.Element
 
   return (
     <Modal isOpen={isShown} toggle={cancelToDelete} className={`${styles['page-comment-delete-modal']}`}>
-      <ModalHeader tag="h4" toggle={cancelToDelete} className="bg-danger text-light">
+      <ModalHeader tag="h4" toggle={cancelToDelete} className="text-danger">
         {headerContent()}
       </ModalHeader>
       <ModalBody>

+ 5 - 7
apps/app/src/components/PageContentFooter.tsx

@@ -1,16 +1,14 @@
 import React from 'react';
 
-import type { IPage, IUser } from '@growi/core';
+import type { IPage, IPagePopulatedToShowRevision } from '@growi/core';
 import dynamic from 'next/dynamic';
 
-import type { AuthorInfoProps } from './AuthorInfo';
-
 import styles from './PageContentFooter.module.scss';
 
-const AuthorInfo = dynamic<AuthorInfoProps>(() => import('./AuthorInfo').then(mod => mod.AuthorInfo), { ssr: false });
+const AuthorInfo = dynamic(() => import('./AuthorInfo').then(mod => mod.AuthorInfo), { ssr: false });
 
 export type PageContentFooterProps = {
-  page: IPage,
+  page: IPage | IPagePopulatedToShowRevision,
 }
 
 export const PageContentFooter = (props: PageContentFooterProps): JSX.Element => {
@@ -29,8 +27,8 @@ export const PageContentFooter = (props: PageContentFooterProps): JSX.Element =>
     <div className={`${styles['page-content-footer']} page-content-footer py-4 d-edit-none d-print-none}`}>
       <div className="container-lg grw-container-convertible">
         <div className="page-meta">
-          <AuthorInfo user={creator as IUser} date={createdAt} mode="create" locate="footer" />
-          <AuthorInfo user={lastUpdateUser as IUser} date={updatedAt} mode="update" locate="footer" />
+          <AuthorInfo user={creator} date={createdAt} mode="create" locate="footer" />
+          <AuthorInfo user={lastUpdateUser} date={updatedAt} mode="update" locate="footer" />
         </div>
       </div>
     </div>

+ 6 - 5
apps/app/src/components/PageControls/PageControls.tsx

@@ -49,6 +49,7 @@ type TagsProps = {
 
 const Tags = (props: TagsProps): JSX.Element => {
   const { onClickEditTagsButton } = props;
+  const { t } = useTranslation();
 
   return (
     <div className="grw-tag-labels-container d-flex align-items-center">
@@ -57,8 +58,8 @@ const Tags = (props: TagsProps): JSX.Element => {
         className="btn btn-sm btn-outline-neutral-secondary"
         onClick={onClickEditTagsButton}
       >
-        <span className="material-symbols-outlined me-1">local_offer</span>
-        Tags
+        <span className="material-symbols-outlined">local_offer</span>
+        <span className="d-none d-sm-inline ms-1">{t('Tags')}</span>
       </button>
     </div>
   );
@@ -138,7 +139,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
   const seenUserIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.seenUserIds ?? []).slice(0, 15) : [];
 
-  const { mutateAndSave: mutatePageControlsX } = usePageControlsX();
+  const { mutate: mutatePageControlsX } = usePageControlsX();
 
   const pageControlsRef = useRef<HTMLDivElement>(null);
   const [pageControlsRect] = useRect(pageControlsRef);
@@ -273,7 +274,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
   return (
     <div className={`${styles['grw-page-controls']} hstack gap-2`} ref={pageControlsRef}>
-      { isDeviceLargerThanMd && (
+      { isViewMode && isDeviceLargerThanMd && (
         <SearchButton />
       )}
 
@@ -284,7 +285,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
       )}
 
       { !hideSubControls && (
-        <div className="hstack gap-1">
+        <div className={`hstack gap-1 ${!isViewMode && 'd-none d-lg-flex'}`}>
           {revisionId != null && _isIPageInfoForOperation && (
             <SubscribeButton
               status={pageInfo.subscriptionStatus}

+ 1 - 1
apps/app/src/components/PageCreateModal.tsx

@@ -296,7 +296,7 @@ const PageCreateModal: React.FC = () => {
       className={`grw-create-page ${styles['grw-create-page']}`}
       autoFocus={false}
     >
-      <ModalHeader tag="h4" toggle={() => closeCreateModal()} className="bg-primary text-light">
+      <ModalHeader tag="h4" toggle={() => closeCreateModal()}>
         {t('New Page')}
       </ModalHeader>
       <ModalBody>

+ 1 - 1
apps/app/src/components/PageDuplicateModal.tsx

@@ -279,7 +279,7 @@ const PageDuplicateModal = (): JSX.Element => {
 
   return (
     <Modal size="lg" isOpen={isOpened} toggle={closeDuplicateModal} data-testid="page-duplicate-modal" className="grw-duplicate-page" autoFocus={false}>
-      <ModalHeader tag="h4" toggle={closeDuplicateModal} className="bg-primary text-light">
+      <ModalHeader tag="h4" toggle={closeDuplicateModal}>
         { t('modal_duplicate.label.Duplicate page') }
       </ModalHeader>
       <ModalBody>

+ 2 - 2
apps/app/src/components/PageEditor/EditorNavbar/EditingUserList.tsx

@@ -27,8 +27,8 @@ export const EditingUserList: FC<Props> = ({ userList }) => {
   }
 
   return (
-    <div className="d-flex flex-column justify-content-end">
-      <div className="d-flex justify-content-end">
+    <div className="d-flex flex-column justify-content-start justify-content-sm-end">
+      <div className="d-flex justify-content-start justify-content-sm-end">
         {firstFourUsers.map(user => (
           <div className="ms-1">
             <UserPicture

+ 5 - 0
apps/app/src/components/PageEditor/EditorNavbar/EditorNavbar.module.scss

@@ -1,3 +1,8 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
 .editor-navbar :global {
   min-height: 72px;
+  @include bs.media-breakpoint-down(sm) {
+    min-height: 96px;
+  }
 }

+ 4 - 3
apps/app/src/components/PageEditor/EditorNavbar/EditorNavbar.tsx

@@ -12,11 +12,12 @@ export const EditorNavbar = (): JSX.Element => {
   const { data: editingUsers } = useEditingUsers();
 
   return (
-    <div className={`${moduleClass} d-flex justify-content-between px-4 py-1`}>
-      <PageHeader />
-      <EditingUserList
+    <div className={`${moduleClass} d-flex flex-column flex-sm-row justify-content-between ps-3 ps-md-5 ps-xl-4 pe-4 py-1 align-items-sm-end`}>
+      <div className="order-2 order-sm-1"><PageHeader /></div>
+      <div className="order-1 order-sm-2"><EditingUserList
         userList={editingUsers?.userList ?? []}
       />
+      </div>
     </div>
   );
 };

+ 12 - 0
apps/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -1,5 +1,7 @@
 import dynamic from 'next/dynamic';
 
+import { useDrawerOpened } from '~/stores/ui';
+
 import styles from './EditorNavbarBottom.module.scss';
 
 const moduleClass = styles['grw-editor-navbar-bottom'];
@@ -8,9 +10,19 @@ const SavePageControls = dynamic(() => import('~/components/SavePageControls').t
 const OptionsSelector = dynamic(() => import('~/components/PageEditor/OptionsSelector').then(mod => mod.OptionsSelector), { ssr: false });
 
 const EditorNavbarBottom = (): JSX.Element => {
+
+  const { mutate: mutateDrawerOpened } = useDrawerOpened();
+
   return (
     <div className="border-top" data-testid="grw-editor-navbar-bottom">
       <div className={`flex-expand-horiz align-items-center p-2 ps-md-3 pe-md-4 ${moduleClass}`}>
+        <a
+          role="button"
+          className="nav-link btn-lg p-2 d-md-none me-3 opacity-50"
+          onClick={() => mutateDrawerOpened(true)}
+        >
+          <span className="material-symbols-outlined fs-2">reorder</span>
+        </a>
         <form className="me-auto">
           <OptionsSelector />
         </form>

+ 1 - 1
apps/app/src/components/PageEditor/GridEditModal.jsx

@@ -191,7 +191,7 @@ class GridEditModal extends React.Component {
     const { t } = this.props;
     return (
       <Modal isOpen={this.state.show} toggle={this.cancel} size="xl" className={`grw-grid-edit-modal ${styles['grw-grid-edit-modal']}`}>
-        <ModalHeader tag="h4" toggle={this.cancel} className="bg-primary text-light">
+        <ModalHeader tag="h4" toggle={this.cancel}>
           {t('grid_edit.create_bootstrap_4_grid')}
         </ModalHeader>
         <ModalBody className="container">

+ 1 - 1
apps/app/src/components/PageEditor/LinkEditModal.tsx

@@ -338,7 +338,7 @@ export const LinkEditModal = (): JSX.Element => {
 
   return (
     <Modal className="link-edit-modal" isOpen={linkEditModalStatus.isOpened} toggle={close} size="lg" autoFocus={false}>
-      <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
+      <ModalHeader tag="h4" toggle={close}>
         {t('link_edit.edit_link')}
       </ModalHeader>
 

+ 2 - 15
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -273,26 +273,12 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     };
   }, [saveAndReturnToViewHandler]);
 
-
-  // TODO: https://redmine.weseek.co.jp/issues/142729
-  // https://regex101.com/r/Wg2Hh6/1
-  // initial caret line
-  useEffect(() => {
-    const untitledPageRegex = /^Untitled-\d+$/;
-    const isNewlyCreatedPage = (
-      currentPage?.wip && currentPage?.latestRevision == null && untitledPageRegex.test(nodePath.basename(currentPage?.path ?? ''))
-    ) ?? false;
-    if (!isNewlyCreatedPage) {
-      codeMirrorEditor?.setCaretLine();
-    }
-  }, [codeMirrorEditor, currentPage]);
-
   // set handler to focus
   useLayoutEffect(() => {
     if (editorMode === EditorMode.Editor) {
       codeMirrorEditor?.focus();
     }
-  }, [codeMirrorEditor, editorMode]);
+  }, [codeMirrorEditor, currentPage, editorMode]);
 
   // Detect indent size from contents (only when users are allowed to change it)
   useEffect(() => {
@@ -367,6 +353,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
       <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
         <div className="page-editor-editor-container flex-expand-vert border-end">
           <CodeMirrorEditorMain
+            isEditorMode={editorMode === EditorMode.Editor}
             onChange={markdownChangedHandler}
             onSave={saveWithShortcut}
             onUpload={uploadHandler}

+ 31 - 4
apps/app/src/components/PageHeader/PageHeader.tsx

@@ -1,6 +1,9 @@
-import type { FC } from 'react';
+import {
+  useCallback, useEffect, useRef, useState,
+} from 'react';
 
 import { useSWRxCurrentPage } from '~/stores/page';
+import { usePageControlsX } from '~/stores/ui';
 
 import { PagePathHeader } from './PagePathHeader';
 import { PageTitleHeader } from './PageTitleHeader';
@@ -9,21 +12,45 @@ import styles from './PageHeader.module.scss';
 
 const moduleClass = styles['page-header'] ?? '';
 
-export const PageHeader: FC = () => {
+export const PageHeader = (): JSX.Element => {
+
   const { data: currentPage } = useSWRxCurrentPage();
+  const { data: pageControlsX } = usePageControlsX();
+  const pageHeaderRef = useRef<HTMLDivElement>(null);
+
+  const [maxWidth, setMaxWidth] = useState<number>();
+
+  const calcMaxWidth = useCallback(() => {
+    if (pageControlsX == null || pageHeaderRef.current == null) {
+      // Length that allows users to use PageHeader functionality.
+      setMaxWidth(300);
+      return;
+    }
+    // At least 10px space between PageHeader and PageControls
+    const maxWidth = pageControlsX - pageHeaderRef.current.getBoundingClientRect().x - 10;
+    setMaxWidth(maxWidth);
+  }, [pageControlsX]);
+
+  useEffect(() => {
+    calcMaxWidth();
+  }, [calcMaxWidth]);
 
   if (currentPage == null) {
     return <></>;
   }
 
   return (
-    <div className={`${moduleClass} w-100`}>
+    <div className={`${moduleClass} w-100`} ref={pageHeaderRef}>
       <PagePathHeader
         currentPage={currentPage}
+        maxWidth={maxWidth}
+        onRenameTerminated={calcMaxWidth}
       />
-      <div className="mt-1">
+      <div className="mt-0 mt-md-1">
         <PageTitleHeader
           currentPage={currentPage}
+          maxWidth={maxWidth}
+          onMoveTerminated={calcMaxWidth}
         />
       </div>
     </div>

+ 5 - 1
apps/app/src/components/PageHeader/PagePathHeader.module.scss

@@ -1,5 +1,4 @@
 .page-path-header :global {
-  max-width: calc(100vw - 650px);
   input {
     min-width: 20px;
     min-height: unset;
@@ -17,4 +16,9 @@
       transform: translateY(12px);
     }
   }
+
+  // Make Truncated elements horizontally scrollable and hide the scroll bar
+  .page-path-header-input {
+    scrollbar-width: none;
+  }
 }

+ 62 - 66
apps/app/src/components/PageHeader/PagePathHeader.tsx

@@ -1,19 +1,21 @@
+import type { ChangeEvent } from 'react';
 import {
   useState, useCallback, memo,
 } from 'react';
-import type { FC } from 'react';
 
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import { useTranslation } from 'next-i18next';
+import { debounce } from 'throttle-debounce';
 
-import { ValidationTarget } from '~/client/util/input-validator';
+import type { InputValidationResult } from '~/client/util/use-input-validator';
+import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
 import LinkedPagePath from '~/models/linked-page-path';
 import { usePageSelectModal } from '~/stores/modal';
 
-import ClosableTextInput from '../Common/ClosableTextInput';
 import { PagePathHierarchicalLink } from '../Common/PagePathHierarchicalLink';
+import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput';
 import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
 import { PageSelectModal } from '../PageSelectModal/PageSelectModal';
 
@@ -25,11 +27,15 @@ const moduleClass = styles['page-path-header'];
 type Props = {
   currentPage: IPagePopulatedToShowRevision,
   className?: string,
+  maxWidth?: number,
+  onRenameTerminated?: () => void,
 }
 
-export const PagePathHeader: FC<Props> = memo((props: Props) => {
+export const PagePathHeader = memo((props: Props): JSX.Element => {
   const { t } = useTranslation();
-  const { currentPage, className } = props;
+  const {
+    currentPage, className, maxWidth, onRenameTerminated,
+  } = props;
 
   const dPagePath = new DevidedPagePath(currentPage.path, true);
   const parentPagePath = dPagePath.former;
@@ -38,68 +44,59 @@ export const PagePathHeader: FC<Props> = memo((props: Props) => {
 
   const [isRenameInputShown, setRenameInputShown] = useState(false);
   const [isHover, setHover] = useState(false);
-  const [editingParentPagePath, setEditingParentPagePath] = useState(parentPagePath);
-
-  // const [isIconHidden, setIsIconHidden] = useState(false);
 
   const { data: PageSelectModalData, open: openPageSelectModal } = usePageSelectModal();
   const isOpened = PageSelectModalData?.isOpened ?? false;
 
-  const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
+  const [validationResult, setValidationResult] = useState<InputValidationResult>();
 
-  const onRenameFinish = useCallback(() => {
-    setRenameInputShown(false);
-  }, []);
+  const inputValidator = useInputValidator(ValidationTarget.PAGE);
 
-  const onRenameFailure = useCallback(() => {
-    setRenameInputShown(true);
-  }, []);
+  const changeHandler = useCallback(async(e: ChangeEvent<HTMLInputElement>) => {
+    const validationResult = inputValidator(e.target.value);
+    setValidationResult(validationResult ?? undefined);
+  }, [inputValidator]);
+  const changeHandlerDebounced = debounce(300, changeHandler);
 
-  const onInputChange = useCallback((inputText: string) => {
-    setEditingParentPagePath(inputText);
-  }, []);
 
-  const onPressEnter = useCallback(() => {
-    const pathToRename = normalizePath(`${editingParentPagePath}/${dPagePath.latter}`);
-    pagePathRenameHandler(pathToRename, onRenameFinish, onRenameFailure);
-  }, [editingParentPagePath, onRenameFailure, onRenameFinish, pagePathRenameHandler, dPagePath.latter]);
+  const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
+
 
-  const onPressEscape = useCallback(() => {
+  const rename = useCallback((inputText) => {
+    const pathToRename = normalizePath(`${inputText}/${dPagePath.latter}`);
+    pagePathRenameHandler(pathToRename,
+      () => {
+        setRenameInputShown(false);
+        setValidationResult(undefined);
+        onRenameTerminated?.();
+      },
+      () => {
+        setRenameInputShown(true);
+      });
+  }, [dPagePath.latter, pagePathRenameHandler, onRenameTerminated]);
+
+  const cancel = useCallback(() => {
     // reset
-    setEditingParentPagePath(parentPagePath);
+    setValidationResult(undefined);
     setRenameInputShown(false);
-  }, [parentPagePath]);
+  }, []);
 
   const onClickEditButton = useCallback(() => {
     // reset
-    setEditingParentPagePath(parentPagePath);
     setRenameInputShown(true);
-  }, [parentPagePath]);
-
-  // TODO: https://redmine.weseek.co.jp/issues/141062
-  // Truncate left side and don't use getElementById
-  //
-  // useEffect(() => {
-  //   const areaElem = document.getElementById('grw-page-path-header-container');
-  //   const linkElem = document.getElementById('grw-page-path-hierarchical-link');
-
-  //   const areaElemWidth = areaElem?.offsetWidth;
-  //   const linkElemWidth = linkElem?.offsetWidth;
-
-  //   if (areaElemWidth && linkElemWidth) {
-  //     setIsIconHidden(linkElemWidth > areaElemWidth);
-  //   }
-  //   else {
-  //     setIsIconHidden(false);
-  //   }
-  // }, [currentPage]);
-  //
-  // const styles: CSSProperties | undefined = isIconHidden ? { direction: 'rtl' } : undefined;
+  }, []);
 
   if (dPagePath.isRoot) {
     return <></>;
   }
 
+
+  const isInvalid = validationResult != null;
+
+  const inputMaxWidth = maxWidth != null
+    ? getAdjustedMaxWidthForAutosizeInput(maxWidth, 'sm', validationResult != null ? false : undefined) - 16
+    : undefined;
+
   return (
     <div
       id="page-path-header"
@@ -108,35 +105,34 @@ export const PagePathHeader: FC<Props> = memo((props: Props) => {
       onMouseLeave={() => setHover(false)}
     >
       <div
-        id="grw-page-path-header-container"
-        className="me-2 d-inline-block overflow-hidden"
+        className="page-path-header-input d-inline-block"
       >
         { isRenameInputShown && (
-          <div className="position-absolute w-100">
-            <ClosableTextInput
-              value={editingParentPagePath}
-              placeholder={t('Input parent page path')}
-              inputClassName="form-control-sm"
-              onPressEnter={onPressEnter}
-              onPressEscape={onPressEscape}
-              onChange={onInputChange}
-              validationTarget={ValidationTarget.PAGE}
-              onClickOutside={onPressEscape}
-            />
+          <div className="position-relative">
+            <div className="position-absolute w-100">
+              <AutosizeSubmittableInput
+                value={parentPagePath}
+                inputClassName={`form-control form-control-sm ${isInvalid ? 'is-invalid' : ''}`}
+                inputStyle={{ maxWidth: inputMaxWidth }}
+                placeholder={t('Input parent page path')}
+                onChange={changeHandlerDebounced}
+                onSubmit={rename}
+                onCancel={cancel}
+                autoFocus
+              />
+            </div>
           </div>
         ) }
-        <div
-          className={`${isRenameInputShown ? 'invisible' : ''} text-truncate`}
-          // style={styles}
-        >
+        <div className={`${isRenameInputShown ? 'invisible' : ''} text-truncate`}>
           <PagePathHierarchicalLink
             linkedPagePath={linkedPagePath}
-            // isIconHidden={isIconHidden}
           />
         </div>
       </div>
 
-      <div className={`page-path-header-buttons d-flex align-items-center ${isHover && !isRenameInputShown ? '' : 'invisible'}`}>
+      <div
+        className={`page-path-header-buttons d-flex align-items-center ms-2 ${isHover && !isRenameInputShown ? '' : 'invisible'}`}
+      >
         <button
           type="button"
           className="btn btn-outline-neutral-secondary me-2 d-flex align-items-center justify-content-center"

+ 5 - 1
apps/app/src/components/PageHeader/PageTitleHeader.module.scss

@@ -1,5 +1,4 @@
 .page-title-header :global {
-  max-width: calc(100vw - 650px);
   input {
     min-width: 20px;
     min-height: unset;
@@ -7,6 +6,11 @@
     line-height: 1em;
     transform: translateX(0.05rem) translateY(0.05rem);
   }
+
+  // Make Truncated elements horizontally scrollable and hide the scroll bar
+  .page-title-header-input {
+    scrollbar-width: none;
+  }
 }
 
 .page-title-header-border-color {

+ 60 - 39
apps/app/src/components/PageHeader/PageTitleHeader.tsx

@@ -1,5 +1,5 @@
-import type { FC } from 'react';
-import { useState, useCallback, useEffect } from 'react';
+import type { ChangeEvent } from 'react';
+import { useState, useCallback } from 'react';
 
 import nodePath from 'path';
 
@@ -9,10 +9,11 @@ import { pathUtils } from '@growi/core/dist/utils';
 import { isMovablePage } from '@growi/core/dist/utils/page-path-utils';
 import { useTranslation } from 'next-i18next';
 
-import { ValidationTarget } from '~/client/util/input-validator';
+import type { InputValidationResult } from '~/client/util/use-input-validator';
+import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
 
-import ClosableTextInput from '../Common/ClosableTextInput';
 import { CopyDropdown } from '../Common/CopyDropdown';
+import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput';
 import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
 
 
@@ -24,11 +25,13 @@ const borderColorClass = styles['page-title-header-border-color'] ?? '';
 type Props = {
   currentPage: IPagePopulatedToShowRevision,
   className?: string,
+  maxWidth?: number,
+  onMoveTerminated?: () => void,
 };
 
-export const PageTitleHeader: FC<Props> = (props) => {
+export const PageTitleHeader = (props: Props): JSX.Element => {
   const { t } = useTranslation();
-  const { currentPage } = props;
+  const { currentPage, maxWidth, onMoveTerminated } = props;
 
   const currentPagePath = currentPage.path;
 
@@ -39,8 +42,10 @@ export const PageTitleHeader: FC<Props> = (props) => {
 
   const [isRenameInputShown, setRenameInputShown] = useState(false);
   const [editedPagePath, setEditedPagePath] = useState(currentPagePath);
+  const [validationResult, setValidationResult] = useState<InputValidationResult>();
 
   const pagePathRenameHandler = usePagePathRenameHandler(currentPage);
+  const inputValidator = useInputValidator(ValidationTarget.PAGE);
 
   const editedPageTitle = nodePath.basename(editedPagePath);
 
@@ -50,28 +55,34 @@ export const PageTitleHeader: FC<Props> = (props) => {
 
   const isNewlyCreatedPage = (currentPage.wip && currentPage.latestRevision == null && untitledPageRegex.test(editedPageTitle)) ?? false;
 
-  const onRenameFinish = useCallback(() => {
-    setRenameInputShown(false);
-  }, []);
-
-  const onRenameFailure = useCallback(() => {
-    setRenameInputShown(true);
-  }, []);
 
-  const onInputChange = useCallback((inputText: string) => {
-    const newPageTitle = pathUtils.removeHeadingSlash(inputText);
+  const changeHandler = useCallback(async(e: ChangeEvent<HTMLInputElement>) => {
+    const newPageTitle = pathUtils.removeHeadingSlash(e.target.value);
     const parentPagePath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage.path));
     const newPagePath = nodePath.resolve(parentPagePath, newPageTitle);
 
     setEditedPagePath(newPagePath);
-  }, [currentPage?.path, setEditedPagePath]);
-
-  const onPressEnter = useCallback(() => {
-    pagePathRenameHandler(editedPagePath, onRenameFinish, onRenameFailure);
-  }, [editedPagePath, onRenameFailure, onRenameFinish, pagePathRenameHandler]);
 
-  const onPressEscape = useCallback(() => {
+    // validation
+    const validationResult = inputValidator(e.target.value);
+    setValidationResult(validationResult ?? undefined);
+  }, [currentPage.path, inputValidator]);
+
+  const rename = useCallback(() => {
+    pagePathRenameHandler(editedPagePath,
+      () => {
+        setRenameInputShown(false);
+        setValidationResult(undefined);
+        onMoveTerminated?.();
+      },
+      () => {
+        setRenameInputShown(true);
+      });
+  }, [editedPagePath, onMoveTerminated, pagePathRenameHandler]);
+
+  const cancel = useCallback(() => {
     setEditedPagePath(currentPagePath);
+    setValidationResult(undefined);
     setRenameInputShown(false);
   }, [currentPagePath]);
 
@@ -84,31 +95,41 @@ export const PageTitleHeader: FC<Props> = (props) => {
     setRenameInputShown(true);
   }, [currentPagePath, isMovable]);
 
-  useEffect(() => {
-    if (isNewlyCreatedPage) {
-      setRenameInputShown(true);
-    }
-  }, [currentPage._id, isNewlyCreatedPage]);
+  // TODO: auto focus when create new page
+  // https://redmine.weseek.co.jp/issues/136128
+  // useEffect(() => {
+  //   if (isNewlyCreatedPage) {
+  //     setRenameInputShown(true);
+  //   }
+  // }, [currentPage._id, isNewlyCreatedPage]);
+
+  const isInvalid = validationResult != null;
+
+  const inputMaxWidth = maxWidth != null
+    ? getAdjustedMaxWidthForAutosizeInput(maxWidth, 'md', validationResult != null ? false : undefined) - 16
+    : undefined;
 
   return (
     <div className={`d-flex ${moduleClass} ${props.className ?? ''} position-relative`}>
-      <div className="me-1 d-inline-block overflow-hidden">
+      <div className="page-title-header-input me-1 d-inline-block">
         { isRenameInputShown && (
-          <div className="position-absolute w-100">
-            <ClosableTextInput
-              value={isNewlyCreatedPage ? '' : editedPageTitle}
-              placeholder={t('Input page name')}
-              inputClassName="fs-4"
-              onPressEnter={onPressEnter}
-              onPressEscape={onPressEscape}
-              onChange={onInputChange}
-              onClickOutside={() => { setRenameInputShown(false) }}
-              validationTarget={ValidationTarget.PAGE}
-            />
+          <div className="position-relative">
+            <div className="position-absolute w-100">
+              <AutosizeSubmittableInput
+                value={isNewlyCreatedPage ? '' : editedPageTitle}
+                inputClassName={`form-control fs-4 ${isInvalid ? 'is-invalid' : ''}`}
+                inputStyle={{ maxWidth: inputMaxWidth }}
+                placeholder={t('Input page name')}
+                onChange={changeHandler}
+                onSubmit={rename}
+                onCancel={cancel}
+                autoFocus
+              />
+            </div>
           </div>
         ) }
         <h1
-          className={`mb-0 px-2 fs-4
+          className={`mb-0 mb-sm-1 px-2 fs-4
             ${isRenameInputShown ? 'invisible' : ''} text-truncate
             ${isMovable ? 'border border-2 rounded-2' : ''} ${borderColorClass}
           `}

+ 1 - 1
apps/app/src/components/PageRenameModal.tsx

@@ -351,7 +351,7 @@ const PageRenameModal = (): JSX.Element => {
 
   return (
     <Modal size="lg" isOpen={isOpened} toggle={closeRenameModal} data-testid="page-rename-modal" autoFocus={false}>
-      <ModalHeader tag="h4" toggle={closeRenameModal} className="bg-primary text-light">
+      <ModalHeader tag="h4" toggle={closeRenameModal}>
         { t('modal_rename.label.Move/Rename page') }
       </ModalHeader>
       <ModalBody>

+ 13 - 10
apps/app/src/components/PageSelectModal/PageSelectModal.tsx

@@ -1,5 +1,5 @@
 import type { FC } from 'react';
-import { useState, useCallback } from 'react';
+import { Suspense, useState, useCallback } from 'react';
 
 import nodePath from 'path';
 
@@ -14,6 +14,7 @@ import { usePageSelectModal } from '~/stores/modal';
 import { useCurrentPagePath, useCurrentPageId, useSWRxCurrentPage } from '~/stores/page';
 
 import { ItemsTree } from '../ItemsTree';
+import ItemsTreeContentSkeleton from '../ItemsTree/ItemsTreeContentSkeleton';
 import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
 
 import { TreeItemForModal } from './TreeItemForModal';
@@ -83,15 +84,17 @@ export const PageSelectModal: FC = () => {
     >
       <ModalHeader toggle={closeModal}>{t('page_select_modal.select_page_location')}</ModalHeader>
       <ModalBody>
-        <ItemsTree
-          CustomTreeItem={TreeItemForModal}
-          isEnableActions={!isGuestUser}
-          isReadOnlyUser={!!isReadOnlyUser}
-          targetPath={path}
-          targetPathOrId={targetPathOrId}
-          targetAndAncestorsData={targetAndAncestorsData}
-          onClickTreeItem={onClickTreeItem}
-        />
+        <Suspense fallback={<ItemsTreeContentSkeleton />}>
+          <ItemsTree
+            CustomTreeItem={TreeItemForModal}
+            isEnableActions={!isGuestUser}
+            isReadOnlyUser={!!isReadOnlyUser}
+            targetPath={path}
+            targetPathOrId={targetPathOrId}
+            targetAndAncestorsData={targetAndAncestorsData}
+            onClickTreeItem={onClickTreeItem}
+          />
+        </Suspense>
       </ModalBody>
       <ModalFooter>
         <Button color="secondary" onClick={onClickCancel}>{t('Cancel')}</Button>

+ 5 - 0
apps/app/src/components/PageSelectModal/TreeItemForModal.module.scss

@@ -0,0 +1,5 @@
+.tree-item-for-modal :global {
+  li {
+    min-height: 36px;
+  }
+}

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