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

Merge pull request #8785 from weseek/master

Release v7.0.4
Yuki Takei 1 год назад
Родитель
Сommit
a5f18968c4
100 измененных файлов с 1877 добавлено и 1386 удалено
  1. 16 0
      apps/app/config/i18next.config.js
  2. 20 9
      apps/app/config/next-i18next.config.js
  3. 8 5
      apps/app/package.json
  4. 1 0
      apps/app/public/static/locales/en_US/commons.json
  5. 17 10
      apps/app/public/static/locales/en_US/translation.json
  6. 1 0
      apps/app/public/static/locales/fr_FR/commons.json
  7. 14 10
      apps/app/public/static/locales/fr_FR/translation.json
  8. 1 0
      apps/app/public/static/locales/ja_JP/commons.json
  9. 17 10
      apps/app/public/static/locales/ja_JP/translation.json
  10. 1 0
      apps/app/public/static/locales/zh_CN/commons.json
  11. 17 10
      apps/app/public/static/locales/zh_CN/translation.json
  12. 15 8
      apps/app/src/client/services/renderer/renderer.tsx
  13. 0 90
      apps/app/src/client/services/renderer/slide-viewer-renderer.tsx
  14. 33 0
      apps/app/src/client/services/side-effects/yjs.ts
  15. 0 32
      apps/app/src/client/util/input-validator.ts
  16. 3 3
      apps/app/src/client/util/locale-utils.ts
  17. 56 0
      apps/app/src/client/util/use-input-validator.ts
  18. 1 1
      apps/app/src/components/Admin/Customize/CustomizePresentationSetting.tsx
  19. 12 13
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  20. 54 8
      apps/app/src/components/Bookmarks/BookmarkFolderNameInput.tsx
  21. 4 8
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  22. 68 0
      apps/app/src/components/Bookmarks/BookmarkItemRenameInput.tsx
  23. 0 1
      apps/app/src/components/Bookmarks/BookmarkMoveToRootBtn.tsx
  24. 0 146
      apps/app/src/components/Common/ClosableTextInput.tsx
  25. 1 1
      apps/app/src/components/Common/Dropdown/PageItemControl.spec.tsx
  26. 2 2
      apps/app/src/components/Common/Dropdown/PageItemControl.tsx
  27. 30 0
      apps/app/src/components/Common/SubmittableInput/AutosizeSubmittableInput.tsx
  28. 23 0
      apps/app/src/components/Common/SubmittableInput/SubmittableInput.tsx
  29. 2 0
      apps/app/src/components/Common/SubmittableInput/index.ts
  30. 7 0
      apps/app/src/components/Common/SubmittableInput/types.d.ts
  31. 80 0
      apps/app/src/components/Common/SubmittableInput/use-submittable.ts
  32. 18 1
      apps/app/src/components/InstallerForm.tsx
  33. 0 153
      apps/app/src/components/ItemsTree/ItemsTree.module.scss
  34. 4 57
      apps/app/src/components/ItemsTree/ItemsTree.tsx
  35. 11 0
      apps/app/src/components/ItemsTree/ItemsTreeContentSkeleton.module.scss
  36. 5 7
      apps/app/src/components/ItemsTree/ItemsTreeContentSkeleton.tsx
  37. 14 2
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  38. 2 0
      apps/app/src/components/Page/DisplaySwitcher.tsx
  39. 13 2
      apps/app/src/components/Page/PageView.tsx
  40. 3 1
      apps/app/src/components/Page/RevisionRenderer.tsx
  41. 26 0
      apps/app/src/components/Page/SlideRenderer.tsx
  42. 15 5
      apps/app/src/components/PageEditor/Preview.tsx
  43. 35 39
      apps/app/src/components/PageHeader/PagePathHeader.tsx
  44. 31 15
      apps/app/src/components/PageHeader/PageTitleHeader.tsx
  45. 7 4
      apps/app/src/components/PagePresentationModal.tsx
  46. 5 0
      apps/app/src/components/PageSelectModal/TreeItemForModal.module.scss
  47. 11 4
      apps/app/src/components/PageSelectModal/TreeItemForModal.tsx
  48. 1 1
      apps/app/src/components/Presentation/Presentation.tsx
  49. 1 1
      apps/app/src/components/Presentation/Slides.tsx
  50. 0 33
      apps/app/src/components/ReactMarkdownComponents/SlideViewer.tsx
  51. 10 6
      apps/app/src/components/ShareLinkPageView.tsx
  52. 2 3
      apps/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx
  53. 9 0
      apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx
  54. 6 0
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  55. 73 6
      apps/app/src/components/Sidebar/PageTree/PageTreeSubstance.tsx
  56. 19 0
      apps/app/src/components/Sidebar/PageTreeItem/CountBadgeForPageTreeItem.tsx
  57. 13 0
      apps/app/src/components/Sidebar/PageTreeItem/CreatingNewPageSpinner.tsx
  58. 0 179
      apps/app/src/components/Sidebar/PageTreeItem/Ellipsis.tsx
  59. 21 2
      apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.module.scss
  60. 28 26
      apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  61. 0 1
      apps/app/src/components/Sidebar/PageTreeItem/index.ts
  62. 235 0
      apps/app/src/components/Sidebar/PageTreeItem/use-page-item-control.tsx
  63. 17 13
      apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  64. 7 0
      apps/app/src/components/Sidebar/Sidebar.tsx
  65. 3 2
      apps/app/src/components/Sidebar/SidebarNav/SecondaryItems.tsx
  66. 4 0
      apps/app/src/components/Skeleton.module.scss
  67. 5 2
      apps/app/src/components/Skeleton.tsx
  68. 1 1
      apps/app/src/components/TreeItem/ItemNode.ts
  69. 2 2
      apps/app/src/components/TreeItem/NewPageInput/NewPageCreateButton.tsx
  70. 6 0
      apps/app/src/components/TreeItem/NewPageInput/NewPageInput.module.scss
  71. 0 79
      apps/app/src/components/TreeItem/NewPageInput/NewPageInput.tsx
  72. 102 42
      apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx
  73. 0 261
      apps/app/src/components/TreeItem/SimpleItem.tsx
  74. 7 0
      apps/app/src/components/TreeItem/SimpleItemContent.module.scss
  75. 46 0
      apps/app/src/components/TreeItem/SimpleItemContent.tsx
  76. 36 0
      apps/app/src/components/TreeItem/TreeItemLayout.module.scss
  77. 235 0
      apps/app/src/components/TreeItem/TreeItemLayout.tsx
  78. 1 0
      apps/app/src/components/TreeItem/_tree-item-variables.scss
  79. 1 1
      apps/app/src/components/TreeItem/index.ts
  80. 10 6
      apps/app/src/components/TreeItem/interfaces/index.ts
  81. 8 0
      apps/app/src/interfaces/websocket.ts
  82. 4 0
      apps/app/src/interfaces/yjs.ts
  83. 18 1
      apps/app/src/pages/[[...path]].page.tsx
  84. 10 5
      apps/app/src/server/models/activity.ts
  85. 6 4
      apps/app/src/server/models/in-app-notification.ts
  86. 3 0
      apps/app/src/server/models/page.ts
  87. 2 2
      apps/app/src/server/models/user.js
  88. 12 6
      apps/app/src/server/routes/apiv3/installer.ts
  89. 4 2
      apps/app/src/server/routes/apiv3/page/create-page.ts
  90. 57 0
      apps/app/src/server/routes/apiv3/page/get-yjs-data.ts
  91. 3 0
      apps/app/src/server/routes/apiv3/page/index.ts
  92. 47 0
      apps/app/src/server/service/i18next.ts
  93. 29 0
      apps/app/src/server/service/page/index.ts
  94. 2 0
      apps/app/src/server/service/page/page-service.ts
  95. 35 2
      apps/app/src/server/service/socket-io.js
  96. 20 7
      apps/app/src/server/service/yjs-connection-manager.ts
  97. 1 0
      apps/app/src/stores/page.tsx
  98. 0 27
      apps/app/src/stores/slide-viewer-renderer.tsx
  99. 4 4
      apps/app/src/stores/ui.tsx
  100. 7 2
      apps/app/src/stores/websocket.tsx

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

+ 8 - 5
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.0.3",
+  "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."
   },
@@ -863,5 +867,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": "このパスではページ コンテンツを作成できません。"
   },
@@ -896,5 +900,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"

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

@@ -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": "无法在此路径中创建页面内容。"
   },
@@ -866,5 +870,8 @@
     "show_wip_page": "显示 WIP",
     "size_s": "尺寸: S",
     "size_l": "尺寸: L"
+  },
+  "create_page": {
+    "untitled": "Untitled"
   }
 }

+ 15 - 8
apps/app/src/client/services/renderer/renderer.tsx

@@ -1,7 +1,6 @@
 import assert from 'assert';
 
 import { isClient } from '@growi/core/dist/utils/browser-utils';
-import * as slides from '@growi/presentation';
 import * as refsGrowiDirective from '@growi/remark-attachment-refs/dist/client';
 import * as drawio from '@growi/remark-drawio';
 // eslint-disable-next-line import/extensions
@@ -19,7 +18,6 @@ import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { LightBox } from '~/components/ReactMarkdownComponents/LightBox';
 import { RichAttachment } from '~/components/ReactMarkdownComponents/RichAttachment';
-import { SlideViewer } from '~/components/ReactMarkdownComponents/SlideViewer';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
 import * as mermaid from '~/features/mermaid';
 import { RehypeSanitizeOption } from '~/interfaces/rehype';
@@ -68,7 +66,6 @@ export const generateViewOptions = (
     attachment.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
-    [slides.remarkPlugin, { isEnabledMarp: config.isEnabledMarp }],
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -84,7 +81,6 @@ export const generateViewOptions = (
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
       attachment.sanitizeOption,
-      slides.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
     )]
@@ -119,7 +115,6 @@ export const generateViewOptions = (
     components.mermaid = mermaid.MermaidViewer;
     components.attachment = RichAttachment;
     components.img = LightBox;
-    components.slide = SlideViewer;
   }
 
   if (config.isEnabledXssPrevention) {
@@ -241,6 +236,21 @@ export const generatePresentationViewOptions = (
   // based on simple view options
   const options = generateSimpleViewOptions(config, pagePath);
 
+  const { rehypePlugins } = options;
+
+
+  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+    ? [sanitize, deepmerge(
+      addLineNumberAttribute.sanitizeOption,
+    )]
+    : () => {};
+
+  // add rehype plugins
+  rehypePlugins.push(
+    addLineNumberAttribute.rehypePlugin,
+    rehypeSanitizePlugin,
+  );
+
   if (config.isEnabledXssPrevention) {
     verifySanitizePlugin(options, false);
   }
@@ -262,7 +272,6 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     attachment.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
-    [slides.remarkPlugin, { isEnabledMarp: config.isEnabledMarp }],
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -281,7 +290,6 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
       lsxGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
-      slides.sanitizeOption,
     )]
     : () => {};
 
@@ -306,7 +314,6 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     components.mermaid = mermaid.MermaidViewer;
     components.attachment = RichAttachment;
     components.img = LightBox;
-    components.slide = SlideViewer;
   }
 
   if (config.isEnabledXssPrevention) {

+ 0 - 90
apps/app/src/client/services/renderer/slide-viewer-renderer.tsx

@@ -1,90 +0,0 @@
-import * as refsGrowiDirective from '@growi/remark-attachment-refs/dist/client';
-import * as drawio from '@growi/remark-drawio';
-// eslint-disable-next-line import/extensions
-import * as lsxGrowiDirective from '@growi/remark-lsx/dist/client';
-import katex from 'rehype-katex';
-import sanitize from 'rehype-sanitize';
-import math from 'remark-math';
-import deepmerge from 'ts-deepmerge';
-import type { Pluggable } from 'unified';
-
-import { LightBox } from '~/components/ReactMarkdownComponents/LightBox';
-import { RichAttachment } from '~/components/ReactMarkdownComponents/RichAttachment';
-import * as mermaid from '~/features/mermaid';
-import { RehypeSanitizeOption } from '~/interfaces/rehype';
-import type { RendererOptions } from '~/interfaces/renderer-options';
-import type { RendererConfig } from '~/interfaces/services/renderer';
-import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
-import * as attachment from '~/services/renderer/remark-plugins/attachment';
-import * as plantuml from '~/services/renderer/remark-plugins/plantuml';
-import * as xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table';
-import {
-  commonSanitizeOption, generateCommonOptions, injectCustomSanitizeOption, verifySanitizePlugin,
-} from '~/services/renderer/renderer';
-
-
-export const generatePresentationViewOptions = (
-    config: RendererConfig,
-    pagePath: string,
-): RendererOptions => {
-  const options = generateCommonOptions(pagePath);
-
-  const { remarkPlugins, rehypePlugins, components } = options;
-
-  // add remark plugins
-  remarkPlugins.push(
-    math,
-    [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri }],
-    drawio.remarkPlugin,
-    mermaid.remarkPlugin,
-    xsvToTable.remarkPlugin,
-    attachment.remarkPlugin,
-    lsxGrowiDirective.remarkPlugin,
-    refsGrowiDirective.remarkPlugin,
-  );
-
-  if (config.xssOption === RehypeSanitizeOption.CUSTOM) {
-    injectCustomSanitizeOption(config);
-  }
-
-
-  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
-    ? [sanitize, deepmerge(
-      commonSanitizeOption,
-      drawio.sanitizeOption,
-      mermaid.sanitizeOption,
-      attachment.sanitizeOption,
-      lsxGrowiDirective.sanitizeOption,
-      refsGrowiDirective.sanitizeOption,
-      addLineNumberAttribute.sanitizeOption,
-    )]
-    : () => {};
-
-  // add rehype plugins
-  rehypePlugins.push(
-    [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
-    [refsGrowiDirective.rehypePlugin, { pagePath }],
-    rehypeSanitizePlugin,
-    addLineNumberAttribute.rehypePlugin,
-    katex,
-  );
-
-  // add components
-  if (components != null) {
-    components.lsx = lsxGrowiDirective.LsxImmutable;
-    components.ref = refsGrowiDirective.RefImmutable;
-    components.refs = refsGrowiDirective.RefsImmutable;
-    components.refimg = refsGrowiDirective.RefImgImmutable;
-    components.refsimg = refsGrowiDirective.RefsImgImmutable;
-    components.gallery = refsGrowiDirective.GalleryImmutable;
-    components.drawio = drawio.DrawioViewer;
-    components.mermaid = mermaid.MermaidViewer;
-    components.attachment = RichAttachment;
-    components.img = LightBox;
-  }
-
-  if (config.isEnabledXssPrevention) {
-    verifySanitizePlugin(options, false);
-  }
-  return options;
-};

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

+ 1 - 1
apps/app/src/components/Admin/Customize/CustomizePresentationSetting.tsx

@@ -53,7 +53,7 @@ const CustomizePresentationSetting = (props: Props): JSX.Element => {
                 href={`${t('admin:customize_settings.presentation_options.marp_in_gorwi_link')}`}
                 target="_blank"
                 rel="noopener noreferrer"
-              >{`${t('admin:customize_settings.presenattion_options.marp_in_growi')}`}
+              >{`${t('admin:customize_settings.presentation_options.marp_in_growi')}`}
               </a>
             </p>
           </CustomizePresentationOption>

+ 12 - 13
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 {
@@ -257,12 +258,13 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
             <FolderIcon isOpen={isOpen} />
           </div>
           {isRenameAction ? (
-            <BookmarkFolderNameInput
-              onPressEnter={rename}
-              onBlur={rename}
-              onPressEscape={cancel}
-              value={name}
-            />
+            <div className="flex-fill">
+              <BookmarkFolderNameInput
+                value={name}
+                onSubmit={rename}
+                onCancel={cancel}
+              />
+            </div>
           ) : (
             <>
               <div className="grw-foldertree-title-anchor ps-1">
@@ -302,13 +304,10 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
         </li>
       </DragAndDropWrapper>
       {isCreateAction && (
-        <div className="flex-fill">
-          <BookmarkFolderNameInput
-            onPressEnter={create}
-            onBlur={create}
-            onPressEscape={cancel}
-          />
-        </div>
+        <BookmarkFolderNameInput
+          onSubmit={create}
+          onCancel={cancel}
+        />
       )}
       {
         renderChildFolder()

+ 54 - 8
apps/app/src/components/Bookmarks/BookmarkFolderNameInput.tsx

@@ -1,22 +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 type { ClosableTextInputProps } from '~/components/Common/ClosableTextInput';
-import ClosableTextInput from '~/components/Common/ClosableTextInput';
+import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput';
+import type { SubmittableInputProps } from '../Common/SubmittableInput/types';
 
 
-type Props = ClosableTextInputProps;
+type Props = Pick<SubmittableInputProps<AutosizeInputProps>, 'value' | 'onSubmit' | 'onCancel'>;
 
 export const BookmarkFolderNameInput = (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.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')}
-        validationTarget={ValidationTarget.FOLDER}
-        {...props}
+        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>
   );
 };

+ 4 - 8
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';
 
@@ -163,13 +162,10 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       >
         { isRenameInputShown
           ? (
-            <ClosableTextInput
+            <BookmarkItemRenameInput
               value={nodePath.basename(bookmarkedPage.path ?? '')}
-              placeholder={t('Input page name')}
-              onPressEnter={rename}
-              onBlur={rename}
-              onPressEscape={() => { setRenameInputShown(false) }}
-              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 - 146
apps/app/src/components/Common/ClosableTextInput.tsx

@@ -1,146 +0,0 @@
-import type { FC } from 'react';
-import React, {
-  memo, useCallback, 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';
-
-export type ClosableTextInputProps = {
-  value?: string
-  placeholder?: string
-  validationTarget?: string,
-  useAutosizeInput?: boolean
-  inputClassName?: string,
-  onPressEnter?(inputText: string): void
-  onPressEscape?(inputText: string): void
-  onBlur?(inputText: string): void
-  onChange?(inputText: string): void
-}
-
-const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextInputProps) => {
-  const { t } = useTranslation();
-  const {
-    validationTarget, onPressEnter, onPressEscape, onBlur, onChange,
-  } = 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 = useCallback(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);
-  }, [t, validationTarget]);
-
-  const changeHandler = useCallback(async(e: React.ChangeEvent<HTMLInputElement>) => {
-    const inputText = e.target.value;
-    createValidation(inputText);
-    setInputText(inputText);
-    setIsAbleToShowAlert(true);
-
-    onChange?.(inputText);
-  }, [createValidation, onChange]);
-
-  const onFocusHandler = useCallback(async(e: React.ChangeEvent<HTMLInputElement>) => {
-    const inputText = e.target.value;
-    await createValidation(inputText);
-  }, [createValidation]);
-
-  const pressEnterHandler = useCallback(() => {
-    if (currentAlertInfo == null) {
-      onPressEnter?.(inputText.trim());
-    }
-  }, [currentAlertInfo, inputText, onPressEnter]);
-
-  const onKeyDownHandler = useCallback((e) => {
-    switch (e.key) {
-      case 'Enter':
-        // Do nothing when composing
-        if (isComposing) {
-          return;
-        }
-        pressEnterHandler();
-        break;
-      case 'Escape':
-        if (isComposing) {
-          return;
-        }
-        onPressEscape?.(inputText.trim());
-        break;
-      default:
-        break;
-    }
-  }, [inputText, isComposing, pressEnterHandler, onPressEscape]);
-
-  /*
-   * Hide when click outside the ref
-   */
-  const onBlurHandler = useCallback(() => {
-    onBlur?.(inputText.trim());
-  }, [inputText, onBlur]);
-
-  // 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: changeHandler,
-    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;

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

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

+ 18 - 1
apps/app/src/components/InstallerForm.tsx

@@ -10,7 +10,7 @@ import { i18n as i18nConfig } from '^/config/next-i18next.config';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
-
+import type { IErrorV3 } from '~/interfaces/errors/v3-error';
 
 import styles from './InstallerForm.module.scss';
 
@@ -28,6 +28,8 @@ const InstallerForm = memo((): JSX.Element => {
   const [isLoading, setIsLoading] = useState(false);
   const [currentLocale, setCurrentLocale] = useState(isSupportedLang ? i18n.language : Lang.en_US);
 
+  const [registerErrors, setRegisterErrors] = useState<IErrorV3[]>([]);
+
   const checkUserName = useCallback(async(event) => {
     const axios = require('axios').create({
       headers: {
@@ -70,6 +72,7 @@ const InstallerForm = memo((): JSX.Element => {
     };
 
     try {
+      setRegisterErrors([]);
       await apiv3Post('/installer', data);
       router.push('/');
     }
@@ -77,6 +80,7 @@ const InstallerForm = memo((): JSX.Element => {
       const err = errs[0];
       const code = err.code;
       setIsLoading(false);
+      setRegisterErrors(errs);
 
       if (code === 'failed_to_login_after_install') {
         toastError(t('installer.failed_to_login_after_install'));
@@ -103,6 +107,19 @@ const InstallerForm = memo((): JSX.Element => {
         </div>
       </div>
       <div className="row mt-2">
+
+        {
+          registerErrors != null && registerErrors.length > 0 && (
+            <p className="alert alert-danger text-center">
+              {registerErrors.map(err => (
+                <span>
+                  {t(err.message)}<br />
+                </span>
+              ))}
+            </p>
+          )
+        }
+
         <form role="form" id="register-form" className="ps-1" onSubmit={submitHandler}>
           <div className="dropdown mb-3">
             <div className="input-group dropdown-with-icon">

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

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

@@ -1,13 +1,13 @@
-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 { 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';
 
@@ -65,6 +65,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();
 
@@ -87,6 +88,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
@@ -113,6 +124,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 (
     <>

+ 13 - 2
apps/app/src/components/Page/PageView.tsx

@@ -4,6 +4,7 @@ import React, {
 
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
+import { useSlidesByFrontmatter } from '@growi/presentation/dist/services';
 import dynamic from 'next/dynamic';
 
 import { useShouldExpandContent } from '~/client/services/layout';
@@ -40,6 +41,7 @@ const Comments = dynamic<CommentsProps>(() => import('../Comments').then(mod =>
 const UsersHomepageFooter = dynamic<UsersHomepageFooterProps>(() => import('../UsersHomepageFooter')
   .then(mod => mod.UsersHomepageFooter), { ssr: false });
 const IdenticalPathPage = dynamic(() => import('../IdenticalPathPage').then(mod => mod.IdenticalPathPage), { ssr: false });
+const SlideRenderer = dynamic(() => import('./SlideRenderer').then(mod => mod.SlideRenderer), { ssr: false });
 
 
 type Props = {
@@ -74,6 +76,10 @@ export const PageView = (props: Props): JSX.Element => {
   const shouldExpandContent = useShouldExpandContent(page);
 
 
+  const markdown = page?.revision?.body;
+  const isSlide = useSlidesByFrontmatter(markdown, rendererConfig.isEnabledMarp);
+
+
   // ***************************  Auto Scroll  ***************************
   useEffect(() => {
     // do nothing if hash is empty
@@ -90,6 +96,7 @@ export const PageView = (props: Props): JSX.Element => {
   }, [isCommentsLoaded]);
   // *******************************  end  *******************************
 
+
   const specialContents = useMemo(() => {
     if (isIdenticalPathPage) {
       return <IdenticalPathPage />;
@@ -128,15 +135,19 @@ export const PageView = (props: Props): JSX.Element => {
       return <NotFoundPage path={pagePath} />;
     }
 
-    const rendererOptions = viewOptions ?? generateSSRViewOptions(rendererConfig, pagePath);
     const markdown = page.revision.body;
+    const rendererOptions = viewOptions ?? generateSSRViewOptions(rendererConfig, pagePath);
 
     return (
       <>
         <PageContentsUtilities />
 
         <div className="flex-expand-vert justify-content-between">
-          <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
+
+          { isSlide != null
+            ? <SlideRenderer marp={isSlide.marp} markdown={markdown} />
+            : <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
+          }
 
           { !isIdenticalPathPage && !isNotFound && (
             <div id="comments-container" ref={commentsContainerRef}>

+ 3 - 1
apps/app/src/components/Page/RevisionRenderer.tsx

@@ -1,6 +1,7 @@
 import React from 'react';
 
-import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
+import type { FallbackProps } from 'react-error-boundary';
+import { ErrorBoundary } from 'react-error-boundary';
 import ReactMarkdown from 'react-markdown';
 
 import type { RendererOptions } from '~/interfaces/renderer-options';
@@ -8,6 +9,7 @@ import loggerFactory from '~/utils/logger';
 
 import 'katex/dist/katex.min.css';
 
+
 const logger = loggerFactory('components:Page:RevisionRenderer');
 
 type Props = {

+ 26 - 0
apps/app/src/components/Page/SlideRenderer.tsx

@@ -0,0 +1,26 @@
+import type { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown';
+
+import { usePresentationViewOptions } from '~/stores/renderer';
+
+import { Slides } from '../Presentation/Slides';
+
+type SlideRendererProps = {
+  markdown: string,
+  marp?: boolean,
+};
+
+export const SlideRenderer = (props: SlideRendererProps): JSX.Element => {
+
+  const { markdown, marp = false } = props;
+
+  const { data: rendererOptions } = usePresentationViewOptions();
+
+  return (
+    <Slides
+      hasMarpFlag={marp}
+      options={{ rendererOptions: rendererOptions as ReactMarkdownOptions }}
+    >
+      {markdown}
+    </Slides>
+  );
+};

+ 15 - 5
apps/app/src/components/PageEditor/Preview.tsx

@@ -1,10 +1,12 @@
 import type { CSSProperties } from 'react';
-import React from 'react';
+
+import { useSlidesByFrontmatter } from '@growi/presentation/dist/services';
 
 import type { RendererOptions } from '~/interfaces/renderer-options';
+import { useIsEnabledMarp } from '~/stores/context';
 
 import RevisionRenderer from '../Page/RevisionRenderer';
-
+import { SlideRenderer } from '../Page/SlideRenderer';
 
 import styles from './Preview.module.scss';
 
@@ -28,17 +30,25 @@ const Preview = (props: Props): JSX.Element => {
     expandContentWidth,
   } = props;
 
+  const { data: isEnabledMarp } = useIsEnabledMarp();
+  const isSlide = useSlidesByFrontmatter(markdown, isEnabledMarp);
+
   const fluidLayoutClass = expandContentWidth ? 'fluid-layout' : '';
 
+
   return (
     <div
       data-testid="page-editor-preview-body"
       className={`${moduleClass} ${fluidLayoutClass} ${pagePath === '/Sidebar' ? 'preview-sidebar' : ''}`}
       style={style}
     >
-      { markdown != null && (
-        <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown}></RevisionRenderer>
-      ) }
+      { markdown != null
+        && (
+          isSlide != null
+            ? <SlideRenderer marp={isSlide.marp} markdown={markdown} />
+            : <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown}></RevisionRenderer>
+        )
+      }
     </div>
   );
 

+ 35 - 39
apps/app/src/components/PageHeader/PagePathHeader.tsx

@@ -1,3 +1,4 @@
+import type { ChangeEvent } from 'react';
 import {
   useState, useCallback, memo,
 } from 'react';
@@ -6,13 +7,15 @@ 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';
 
@@ -42,11 +45,20 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
   const [isRenameInputShown, setRenameInputShown] = useState(false);
   const [isHover, setHover] = useState(false);
 
-  // const [isIconHidden, setIsIconHidden] = useState(false);
-
   const { data: PageSelectModalData, open: openPageSelectModal } = usePageSelectModal();
   const isOpened = PageSelectModalData?.isOpened ?? false;
 
+  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 pagePathRenameHandler = usePagePathRenameHandler(currentPage);
 
 
@@ -55,6 +67,7 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
     pagePathRenameHandler(pathToRename,
       () => {
         setRenameInputShown(false);
+        setValidationResult(undefined);
         onRenameTerminated?.();
       },
       () => {
@@ -64,6 +77,7 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
 
   const cancel = useCallback(() => {
     // reset
+    setValidationResult(undefined);
     setRenameInputShown(false);
   }, []);
 
@@ -72,70 +86,52 @@ export const PagePathHeader = memo((props: Props): JSX.Element => {
     setRenameInputShown(true);
   }, []);
 
-  // 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"
       className={`d-flex ${moduleClass} ${className ?? ''} small position-relative ms-2`}
-      style={{ maxWidth }}
       onMouseEnter={() => setHover(true)}
       onMouseLeave={() => setHover(false)}
     >
       <div
-        className="page-path-header-input d-inline-block overflow-x-scroll"
+        className="page-path-header-input d-inline-block"
       >
         { isRenameInputShown && (
           <div className="position-relative">
             <div className="position-absolute w-100">
-              <ClosableTextInput
+              <AutosizeSubmittableInput
                 value={parentPagePath}
+                inputClassName={`form-control form-control-sm ${isInvalid ? 'is-invalid' : ''}`}
+                inputStyle={{ maxWidth: inputMaxWidth }}
                 placeholder={t('Input parent page path')}
-                inputClassName="form-control-sm"
-                onPressEnter={rename}
-                onPressEscape={cancel}
-                onBlur={rename}
-                validationTarget={ValidationTarget.PAGE}
-                useAutosizeInput
+                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'}`}
+        className={`page-path-header-buttons d-flex align-items-center ms-2 ${isHover && !isRenameInputShown ? '' : 'invisible'}`}
       >
         <button
           type="button"

+ 31 - 15
apps/app/src/components/PageHeader/PageTitleHeader.tsx

@@ -1,3 +1,4 @@
+import type { ChangeEvent } from 'react';
 import { useState, useCallback } from 'react';
 
 import nodePath from 'path';
@@ -8,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';
 
 
@@ -40,8 +42,10 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
 
   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);
 
@@ -51,18 +55,24 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
 
   const isNewlyCreatedPage = (currentPage.wip && currentPage.latestRevision == null && untitledPageRegex.test(editedPageTitle)) ?? false;
 
-  const inputChangeHandler = 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]);
+
+    // validation
+    const validationResult = inputValidator(e.target.value);
+    setValidationResult(validationResult ?? undefined);
+  }, [currentPage.path, inputValidator]);
 
   const rename = useCallback(() => {
     pagePathRenameHandler(editedPagePath,
       () => {
         setRenameInputShown(false);
+        setValidationResult(undefined);
         onMoveTerminated?.();
       },
       () => {
@@ -72,6 +82,7 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
 
   const cancel = useCallback(() => {
     setEditedPagePath(currentPagePath);
+    setValidationResult(undefined);
     setRenameInputShown(false);
   }, [currentPagePath]);
 
@@ -92,22 +103,27 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
   //   }
   // }, [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`} style={{ maxWidth }}>
-      <div className="page-title-header-input me-1 d-inline-block overflow-x-scroll">
+    <div className={`d-flex ${moduleClass} ${props.className ?? ''} position-relative`}>
+      <div className="page-title-header-input me-1 d-inline-block">
         { isRenameInputShown && (
           <div className="position-relative">
             <div className="position-absolute w-100">
-              <ClosableTextInput
+              <AutosizeSubmittableInput
                 value={isNewlyCreatedPage ? '' : editedPageTitle}
+                inputClassName={`form-control fs-4 ${isInvalid ? 'is-invalid' : ''}`}
+                inputStyle={{ maxWidth: inputMaxWidth }}
                 placeholder={t('Input page name')}
-                inputClassName="fs-4"
-                onPressEnter={rename}
-                onPressEscape={cancel}
-                onChange={inputChangeHandler}
-                onBlur={rename}
-                validationTarget={ValidationTarget.PAGE}
-                useAutosizeInput
+                onChange={changeHandler}
+                onSubmit={rename}
+                onCancel={cancel}
+                autoFocus
               />
             </div>
           </div>

+ 7 - 4
apps/app/src/components/PagePresentationModal.tsx

@@ -1,6 +1,7 @@
 import React, { useCallback } from 'react';
 
-import type { PresentationProps } from '@growi/presentation';
+import type { PresentationProps } from '@growi/presentation/dist/client';
+import { useSlidesByFrontmatter } from '@growi/presentation/dist/services';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useFullScreen } from '@growi/ui/dist/utils';
 import dynamic from 'next/dynamic';
@@ -39,6 +40,10 @@ const PagePresentationModal = (): JSX.Element => {
 
   const { data: isEnabledMarp } = useIsEnabledMarp();
 
+  const markdown = currentPage?.revision?.body;
+
+  const isSlide = useSlidesByFrontmatter(markdown, isEnabledMarp);
+
   const toggleFullscreenHandler = useCallback(() => {
     if (fullscreen.active) {
       fullscreen.exit();
@@ -61,8 +66,6 @@ const PagePresentationModal = (): JSX.Element => {
     return <></>;
   }
 
-  const markdown = currentPage?.revision?.body;
-
   return (
     <Modal
       isOpen={isOpen}
@@ -92,7 +95,7 @@ const PagePresentationModal = (): JSX.Element => {
               },
               isDarkMode,
             }}
-            isEnabledMarp={isEnabledMarp}
+            marp={isSlide?.marp}
           >
             {markdown}
           </Presentation>

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

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

+ 11 - 4
apps/app/src/components/PageSelectModal/TreeItemForModal.tsx

@@ -1,10 +1,15 @@
 import type { FC } from 'react';
 
 import {
-  SimpleItem, useNewPageInput, type TreeItemProps,
+  TreeItemLayout, useNewPageInput, type TreeItemProps,
 } from '../TreeItem';
 
 
+import styles from './TreeItemForModal.module.scss';
+
+const moduleClass = styles['tree-item-for-modal'];
+
+
 type PageTreeItemProps = TreeItemProps & {
   key?: React.Key | null,
 };
@@ -16,9 +21,11 @@ export const TreeItemForModal: FC<PageTreeItemProps> = (props) => {
   const { Input: NewPageInput, CreateButton: NewPageCreateButton } = useNewPageInput();
 
   return (
-    <SimpleItem
+    <TreeItemLayout
       key={props.key}
+      className={moduleClass}
       targetPathOrId={props.targetPathOrId}
+      itemLevel={props.itemLevel}
       itemNode={props.itemNode}
       isOpen={isOpen}
       isEnableActions={props.isEnableActions}
@@ -26,9 +33,9 @@ export const TreeItemForModal: FC<PageTreeItemProps> = (props) => {
       onClickDuplicateMenuItem={props.onClickDuplicateMenuItem}
       onClickDeleteMenuItem={props.onClickDeleteMenuItem}
       onRenamed={props.onRenamed}
-      customNextComponents={[NewPageInput]}
+      customHeadOfChildrenComponents={[NewPageInput]}
       itemClass={TreeItemForModal}
-      customEndComponents={[NewPageCreateButton]}
+      customHoveredEndComponents={[NewPageCreateButton]}
       onClick={onClick}
     />
   );

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

@@ -1,4 +1,4 @@
-import { Presentation as PresentationSubstance, type PresentationProps } from '@growi/presentation';
+import { Presentation as PresentationSubstance, type PresentationProps } from '@growi/presentation/dist/client';
 
 import '@growi/presentation/dist/style.css';
 

+ 1 - 1
apps/app/src/components/Presentation/Slides.tsx

@@ -1,4 +1,4 @@
-import { Slides as SlidesSubstance, type SlidesProps } from '@growi/presentation';
+import { Slides as SlidesSubstance, type SlidesProps } from '@growi/presentation/dist/client';
 
 import '@growi/presentation/dist/style.css';
 

+ 0 - 33
apps/app/src/components/ReactMarkdownComponents/SlideViewer.tsx

@@ -1,33 +0,0 @@
-import React from 'react';
-
-import dynamic from 'next/dynamic';
-import { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown';
-
-import { usePresentationViewOptions } from '~/stores/slide-viewer-renderer';
-
-
-const Slides = dynamic(() => import('../Presentation/Slides').then(mod => mod.Slides), { ssr: false });
-
-type SlideViewerProps = {
-  marp: string | undefined,
-  children: string,
-}
-
-export const SlideViewer: React.FC<SlideViewerProps> = React.memo((props: SlideViewerProps) => {
-  const {
-    marp, children,
-  } = props;
-
-  const { data: rendererOptions } = usePresentationViewOptions();
-
-  return (
-    <Slides
-      hasMarpFlag={marp != null}
-      options={{ rendererOptions: rendererOptions as ReactMarkdownOptions }}
-    >
-      {children}
-    </Slides>
-  );
-});
-
-SlideViewer.displayName = 'SlideViewer';

+ 10 - 6
apps/app/src/components/ShareLinkPageView.tsx

@@ -1,12 +1,14 @@
 import React, { useMemo } from 'react';
 
 import type { IPagePopulatedToShowRevision } from '@growi/core';
+import { useSlidesByFrontmatter } from '@growi/presentation/dist/services';
 import dynamic from 'next/dynamic';
 
 import { useShouldExpandContent } from '~/client/services/layout';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IShareLinkHasId } from '~/interfaces/share-link';
 import { generateSSRViewOptions } from '~/services/renderer/renderer';
+import { useIsEnabledMarp } from '~/stores/context';
 import { useIsNotFound } from '~/stores/page';
 import { useViewOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
@@ -23,7 +25,7 @@ const logger = loggerFactory('growi:Page');
 
 const PageSideContents = dynamic<PageSideContentsProps>(() => import('./PageSideContents').then(mod => mod.PageSideContents), { ssr: false });
 const ForbiddenPage = dynamic(() => import('./ForbiddenPage'), { ssr: false });
-
+const SlideRenderer = dynamic(() => import('./Page/SlideRenderer').then(mod => mod.SlideRenderer), { ssr: false });
 
 type Props = {
   pagePath: string,
@@ -47,6 +49,10 @@ export const ShareLinkPageView = (props: Props): JSX.Element => {
 
   const shouldExpandContent = useShouldExpandContent(page);
 
+  const markdown = page?.revision?.body;
+
+  const isSlide = useSlidesByFrontmatter(markdown, rendererConfig.isEnabledMarp);
+
   const isNotFound = isNotFoundMeta || page == null || shareLink == null;
 
   const specialContents = useMemo(() => {
@@ -85,11 +91,9 @@ export const ShareLinkPageView = (props: Props): JSX.Element => {
     const rendererOptions = viewOptions ?? generateSSRViewOptions(rendererConfig, pagePath);
     const markdown = page.revision.body;
 
-    return (
-      <>
-        <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
-      </>
-    );
+    return isSlide != null
+      ? <SlideRenderer marp={isSlide.marp} markdown={markdown} />
+      : <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />;
   };
 
   return (

+ 2 - 3
apps/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx

@@ -58,9 +58,8 @@ export const BookmarkContents = (): JSX.Element => {
       {isCreateAction && (
         <div className="col-12 mb-2 ">
           <BookmarkFolderNameInput
-            onPressEnter={create}
-            onBlur={create}
-            onPressEscape={cancel}
+            onSubmit={create}
+            onCancel={cancel}
           />
         </div>
       )}

+ 9 - 0
apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx

@@ -8,6 +8,7 @@ import type { LabelType } from '~/interfaces/template';
 
 type DropendMenuProps = {
   onClickCreateNewPage: () => Promise<void>
+  onClickOpenPageCreateModal: () => void
   onClickCreateTodaysMemo: () => Promise<void>
   onClickCreateTemplate?: (label: LabelType) => Promise<void>
   todaysPath: string | null,
@@ -16,6 +17,7 @@ type DropendMenuProps = {
 export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element => {
   const {
     onClickCreateNewPage,
+    onClickOpenPageCreateModal,
     onClickCreateTodaysMemo,
     onClickCreateTemplate,
     todaysPath,
@@ -34,6 +36,13 @@ export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element =>
         {t('create_page_dropdown.new_page')}
       </DropdownItem>
 
+      <DropdownItem
+        onClick={onClickOpenPageCreateModal}
+      >
+        {t('create_page_dropdown.open_page_create_modal')}
+      </DropdownItem>
+
+
       { todaysPath != null && (
         <>
           <DropdownItem divider />

+ 6 - 0
apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx

@@ -4,6 +4,8 @@ import { Dropdown } from 'reactstrap';
 
 import { useCreateTemplatePage } from '~/client/services/create-page';
 import { useToastrOnError } from '~/client/services/use-toastr-on-error';
+import { usePageCreateModal } from '~/stores/modal';
+import { useCurrentPagePath } from '~/stores/page';
 
 import { CreateButton } from './CreateButton';
 import { DropendMenu } from './DropendMenu';
@@ -16,6 +18,9 @@ export const PageCreateButton = React.memo((): JSX.Element => {
 
   const [dropdownOpen, setDropdownOpen] = useState(false);
 
+  const { open: openPageCreateModal } = usePageCreateModal();
+  const { data: currentPagePath } = useCurrentPagePath();
+
   const { createNewPage, isCreating: isNewPageCreating } = useCreateNewPage();
   // TODO: https://redmine.weseek.co.jp/issues/138806
   const { createTodaysMemo, isCreating: isTodaysPageCreating, todaysPath } = useCreateTodaysMemo();
@@ -64,6 +69,7 @@ export const PageCreateButton = React.memo((): JSX.Element => {
           <DropendToggle />
           <DropendMenu
             onClickCreateNewPage={createNewPageWithToastr}
+            onClickOpenPageCreateModal={() => openPageCreateModal(currentPagePath)}
             onClickCreateTodaysMemo={createTodaysMemoWithToastr}
             onClickCreateTemplate={isTemplatePageCreatable ? createTemplateWithToastr : undefined}
             todaysPath={todaysPath}

+ 73 - 6
apps/app/src/components/Sidebar/PageTree/PageTreeSubstance.tsx

@@ -1,13 +1,20 @@
-import React, { memo, useCallback } from 'react';
+import React, {
+  memo, useCallback, useEffect, useMemo, useRef, useState,
+} from 'react';
 
 import { useTranslation } from 'next-i18next';
 import {
   UncontrolledButtonDropdown, DropdownMenu, DropdownToggle, DropdownItem,
 } from 'reactstrap';
+import { debounce } from 'throttle-debounce';
 
 import { useTargetAndAncestors, useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
-import { mutatePageTree, useSWRxRootPage, useSWRxV5MigrationStatus } from '~/stores/page-listing';
+import {
+  mutatePageTree, useSWRxPageAncestorsChildren, useSWRxRootPage, useSWRxV5MigrationStatus,
+} from '~/stores/page-listing';
+import { useSidebarScrollerRef } from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
 
 import { ItemsTree } from '../../ItemsTree/ItemsTree';
 import { PageTreeItem } from '../PageTreeItem';
@@ -15,6 +22,8 @@ import { SidebarHeaderReloadButton } from '../SidebarHeaderReloadButton';
 
 import { PrivateLegacyPagesLink } from './PrivateLegacyPagesLink';
 
+const logger = loggerFactory('growi:cli:PageTreeSubstance');
+
 type HeaderProps = {
   isWipPageShown: boolean,
   onWipPageShownChange?: () => void
@@ -91,6 +100,65 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
   const { data: migrationStatus } = useSWRxV5MigrationStatus({ suspense: true });
 
   const targetPathOrId = targetId || currentPath;
+  const path = currentPath || '/';
+
+  const { data: ancestorsChildrenResult } = useSWRxPageAncestorsChildren(path, { suspense: true });
+  const { data: rootPageResult } = useSWRxRootPage({ suspense: true });
+  const { data: sidebarScrollerRef } = useSidebarScrollerRef();
+  const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false);
+
+  const rootElemRef = useRef(null);
+
+  // ***************************  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;
+
+    // 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 (isInitialScrollCompleted || ancestorsChildrenResult == null || rootPageResult == null) {
+      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, scrollOnInitDebounced, ancestorsChildrenResult, rootPageResult]);
+  // *******************************  end  *******************************
+
 
   if (!migrationStatus?.isV5Compatible) {
     return <PageTreeUnavailable />;
@@ -103,10 +171,9 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
     return null;
   }
 
-  const path = currentPath || '/';
 
   return (
-    <>
+    <div ref={rootElemRef} className="pt-4">
       <ItemsTree
         isEnableActions={!isGuestUser}
         isReadOnlyUser={!!isReadOnlyUser}
@@ -118,13 +185,13 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
       />
 
       {!isGuestUser && !isReadOnlyUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
-        <div className="grw-pagetree-footer border-top py-3 w-100">
+        <div className="grw-pagetree-footer border-top mt-4 py-2 w-100">
           <div className="private-legacy-pages-link px-3 py-2">
             <PrivateLegacyPagesLink />
           </div>
         </div>
       )}
-    </>
+    </div>
   );
 });
 

+ 19 - 0
apps/app/src/components/Sidebar/PageTreeItem/CountBadgeForPageTreeItem.tsx

@@ -0,0 +1,19 @@
+import CountBadge from '~/components/Common/CountBadge';
+import type { TreeItemToolProps } from '~/components/TreeItem';
+import { usePageTreeDescCountMap } from '~/stores/ui';
+
+export const CountBadgeForPageTreeItem = (props: TreeItemToolProps): JSX.Element => {
+  const { getDescCount } = usePageTreeDescCountMap();
+
+  const { page } = props.itemNode;
+
+  const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
+
+  return (
+    <>
+      {descendantCount > 0 && (
+        <CountBadge count={descendantCount} />
+      )}
+    </>
+  );
+};

+ 13 - 0
apps/app/src/components/Sidebar/PageTreeItem/CreatingNewPageSpinner.tsx

@@ -0,0 +1,13 @@
+import { LoadingSpinner } from '@growi/ui/dist/components';
+
+export const CreatingNewPageSpinner = ({ show }: { show?: boolean }): JSX.Element => {
+  if (!show) {
+    return <></>;
+  }
+
+  return (
+    <div className="text-center opacity-50 py-2">
+      <LoadingSpinner className="mr-1" />
+    </div>
+  );
+};

+ 0 - 179
apps/app/src/components/Sidebar/PageTreeItem/Ellipsis.tsx

@@ -1,179 +0,0 @@
-import type { FC } from 'react';
-import React, {
-  useCallback, useState,
-} from 'react';
-
-import nodePath from 'path';
-
-
-import type { IPageInfoAll, IPageToDeleteWithMeta } from '@growi/core';
-import { pathUtils } from '@growi/core/dist/utils';
-import { useTranslation } from 'next-i18next';
-import { DropdownToggle } from 'reactstrap';
-
-import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
-import { apiv3Put } from '~/client/util/apiv3-client';
-import { ValidationTarget } from '~/client/util/input-validator';
-import { toastError, toastSuccess } from '~/client/util/toastr';
-import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
-import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
-import { useSWRMUTxPageInfo } from '~/stores/page';
-
-import ClosableTextInput from '../../Common/ClosableTextInput';
-import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
-import {
-  type TreeItemToolProps, NotDraggableForClosableTextInput, SimpleItemTool,
-} from '../../TreeItem';
-
-export const Ellipsis: FC<TreeItemToolProps> = (props) => {
-  const [isRenameInputShown, setRenameInputShown] = useState(false);
-  const { t } = useTranslation();
-
-  const {
-    itemNode, onRenamed, onClickDuplicateMenuItem,
-    onClickDeleteMenuItem, isEnableActions, isReadOnlyUser,
-  } = props;
-
-  const { page } = itemNode;
-
-  const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
-  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(page._id ?? null);
-
-  const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
-    const bookmarkOperation = _newValue ? bookmark : unbookmark;
-    await bookmarkOperation(_pageId);
-    mutateCurrentUserBookmarks();
-    mutatePageInfo();
-  };
-
-  const duplicateMenuItemClickHandler = useCallback((): void => {
-    if (onClickDuplicateMenuItem == null) {
-      return;
-    }
-
-    const { _id: pageId, path } = page;
-
-    if (pageId == null || path == null) {
-      throw Error('Any of _id and path must not be null.');
-    }
-
-    const pageToDuplicate = { pageId, path };
-
-    onClickDuplicateMenuItem(pageToDuplicate);
-  }, [onClickDuplicateMenuItem, page]);
-
-  const renameMenuItemClickHandler = useCallback(() => {
-    setRenameInputShown(true);
-  }, []);
-
-  const cancel = useCallback(() => {
-    setRenameInputShown(false);
-  }, []);
-
-  const rename = useCallback(async(inputText) => {
-    if (inputText.trim() === '') {
-      return cancel();
-    }
-
-    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(page.path ?? ''));
-    const newPagePath = nodePath.resolve(parentPath, inputText);
-
-    if (newPagePath === page.path) {
-      setRenameInputShown(false);
-      return;
-    }
-
-    try {
-      setRenameInputShown(false);
-      await apiv3Put('/pages/rename', {
-        pageId: page._id,
-        revisionId: page.revision,
-        newPagePath,
-      });
-
-      onRenamed?.(page.path, newPagePath);
-
-      toastSuccess(t('renamed_pages', { path: page.path }));
-    }
-    catch (err) {
-      setRenameInputShown(true);
-      toastError(err);
-    }
-  }, [cancel, onRenamed, page._id, page.path, page.revision, t]);
-
-  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
-    if (onClickDeleteMenuItem == null) {
-      return;
-    }
-
-    if (page._id == null || page.path == null) {
-      throw Error('_id and path must not be null.');
-    }
-
-    const pageToDelete: IPageToDeleteWithMeta = {
-      data: {
-        _id: page._id,
-        revision: page.revision as string,
-        path: page.path,
-      },
-      meta: pageInfo,
-    };
-
-    onClickDeleteMenuItem(pageToDelete);
-  }, [onClickDeleteMenuItem, page]);
-
-  const pathRecoveryMenuItemClickHandler = async(pageId: string): Promise<void> => {
-    try {
-      await resumeRenameOperation(pageId);
-      toastSuccess(t('page_operation.paths_recovered'));
-    }
-    catch {
-      toastError(t('page_operation.path_recovery_failed'));
-    }
-  };
-
-  const hasChildren = page.descendantCount ? page.descendantCount > 0 : false;
-
-  return (
-    <>
-      {isRenameInputShown ? (
-        <div className={`position-absolute ${hasChildren ? 'ms-5' : 'ms-4'}`}>
-          <NotDraggableForClosableTextInput>
-            <ClosableTextInput
-              value={nodePath.basename(page.path ?? '')}
-              placeholder={t('Input page name')}
-              onPressEnter={rename}
-              onBlur={rename}
-              onPressEscape={cancel}
-              validationTarget={ValidationTarget.PAGE}
-            />
-          </NotDraggableForClosableTextInput>
-        </div>
-      ) : (
-        <SimpleItemTool itemNode={itemNode} isEnableActions={false} isReadOnlyUser={false} />
-      )}
-      <NotAvailableForGuest>
-        <div className="grw-pagetree-control d-flex">
-          <PageItemControl
-            pageId={page._id}
-            isEnableActions={isEnableActions}
-            isReadOnlyUser={isReadOnlyUser}
-            onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
-            onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
-            onClickRenameMenuItem={renameMenuItemClickHandler}
-            onClickDeleteMenuItem={deleteMenuItemClickHandler}
-            onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
-            isInstantRename
-            // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
-            operationProcessData={page.processData}
-          >
-            {/* pass the color property to reactstrap dropdownToggle props. https://6-4-0--reactstrap.netlify.app/components/dropdowns/  */}
-            <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
-              <span id="option-button-in-page-tree" className="material-symbols-outlined p-1">more_vert</span>
-            </DropdownToggle>
-          </PageItemControl>
-        </div>
-      </NotAvailableForGuest>
-    </>
-  );
-};

+ 21 - 2
apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.module.scss

@@ -1,8 +1,26 @@
 @use '@growi/core/scss/bootstrap/init' as bs;
 
+
+// fix height
+.page-tree-item :global {
+  li {
+    min-height: 40px;
+  }
+}
+
+
 // == Colors
+
+// drag over
+.page-tree-item :global {
+  .drag-over {
+    background-color: var(--bs-list-group-action-active-bg);
+  }
+}
+
 @include bs.color-mode(light) {
-  .pagetree-item :global {
+  // button
+  .page-tree-item :global {
     .list-group-item-action {
       .btn-page-item-control {
         --bs-btn-bg: transparent;
@@ -14,7 +32,8 @@
 }
 
 @include bs.color-mode(dark) {
-  .pagetree-item :global {
+  // button
+  .page-tree-item :global {
     .list-group-item-action {
       .btn-page-item-control {
         --bs-btn-bg: transparent;

+ 28 - 26
apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -19,13 +19,18 @@ import loggerFactory from '~/utils/logger';
 
 import type { ItemNode } from '../../TreeItem';
 import {
-  SimpleItem, useNewPageInput, type TreeItemProps,
+  TreeItemLayout, useNewPageInput, type TreeItemProps,
 } from '../../TreeItem';
 
-import { Ellipsis } from './Ellipsis';
+import { CountBadgeForPageTreeItem } from './CountBadgeForPageTreeItem';
+import { CreatingNewPageSpinner } from './CreatingNewPageSpinner';
+import { usePageItemControl } from './use-page-item-control';
 
 import styles from './PageTreeItem.module.scss';
 
+const moduleClass = styles['page-tree-item'] ?? '';
+
+
 const logger = loggerFactory('growi:cli:Item');
 
 export const PageTreeItem: FC<TreeItemProps> = (props) => {
@@ -56,10 +61,14 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
 
   const { page } = itemNode;
   const [isOpen, setIsOpen] = useState(_isOpen);
-  const [shouldHide, setShouldHide] = useState(false);
 
   const { mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
 
+  const {
+    showRenameInput, Control, RenameInput,
+  } = usePageItemControl();
+  const { isProcessingSubmission, Input: NewPageInput, CreateButton: NewPageCreateButton } = useNewPageInput();
+
   const itemSelectedHandler = useCallback((page: IPageForItem) => {
     if (page.path == null || page._id == null) {
       return;
@@ -70,17 +79,6 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
     router.push(link);
   }, [router]);
 
-  const displayDroppedItemByPageId = useCallback((pageId) => {
-    const target = document.getElementById(`pagetree-item-${pageId}`);
-    if (target == null) {
-      return;
-    }
-    //   // wait 500ms to avoid removing before d-none is set by useDrag end() callback
-    setTimeout(() => {
-      target.classList.remove('d-none');
-    }, 500);
-  }, []);
-
   const [, drag] = useDrag({
     type: 'PAGE_TREE',
     item: { page },
@@ -93,9 +91,6 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
     end: (item, monitor) => {
       // in order to set d-none to dropped Item
       const dropResult = monitor.getDropResult();
-      if (dropResult != null) {
-        setShouldHide(true);
-      }
     },
     collect: monitor => ({
       isDragging: monitor.isDragging(),
@@ -129,8 +124,6 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
       setIsOpen(true);
     }
     catch (err) {
-      // display the dropped item
-      displayDroppedItemByPageId(droppedPage._id);
       if (err.code === 'operation__blocked') {
         toastWarning(t('pagetree.you_cannot_move_this_page_now'));
       }
@@ -165,15 +158,21 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
     [page],
   );
 
-  const itemRef = (c) => { drag(c); drop(c) };
+  const itemRef = (c) => {
+    // do not apply when RenameInput is shown
+    if (showRenameInput) return;
 
-  const mainClassName = `${isOver ? 'grw-pagetree-is-over' : ''} ${shouldHide ? 'd-none' : ''}`;
+    drag(c);
+    drop(c);
+  };
 
-  const { Input: NewPageInput, CreateButton: NewPageCreateButton } = useNewPageInput();
+  const itemClassName = `${isOver ? 'drag-over' : ''}`;
 
   return (
-    <SimpleItem
+    <TreeItemLayout
+      className={moduleClass}
       targetPathOrId={props.targetPathOrId}
+      itemLevel={props.itemLevel}
       itemNode={props.itemNode}
       isOpen={isOpen}
       isEnableActions={props.isEnableActions}
@@ -185,9 +184,12 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
       onRenamed={props.onRenamed}
       itemRef={itemRef}
       itemClass={PageTreeItem}
-      mainClassName={mainClassName}
-      customEndComponents={[Ellipsis, NewPageCreateButton]}
-      customNextComponents={[NewPageInput]}
+      itemClassName={itemClassName}
+      customEndComponents={[CountBadgeForPageTreeItem]}
+      customHoveredEndComponents={[Control, NewPageCreateButton]}
+      customHeadOfChildrenComponents={[NewPageInput, () => <CreatingNewPageSpinner show={isProcessingSubmission} />]}
+      showAlternativeContent={showRenameInput}
+      customAlternativeComponents={[RenameInput]}
     />
   );
 };

+ 0 - 1
apps/app/src/components/Sidebar/PageTreeItem/index.ts

@@ -1,2 +1 @@
 export * from './PageTreeItem';
-export * from './Ellipsis';

+ 235 - 0
apps/app/src/components/Sidebar/PageTreeItem/use-page-item-control.tsx

@@ -0,0 +1,235 @@
+import type { ChangeEvent, FC } from 'react';
+import React, {
+  useCallback, useRef, useState,
+} from 'react';
+
+import nodePath from 'path';
+
+import type { IPageInfoAll, IPageToDeleteWithMeta } from '@growi/core';
+import { pathUtils } from '@growi/core/dist/utils';
+import { useRect } from '@growi/ui/dist/utils';
+import { useTranslation } from 'next-i18next';
+import { DropdownToggle } from 'reactstrap';
+import { debounce } from 'throttle-debounce';
+
+import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import { ValidationTarget, useInputValidator, type InputValidationResult } from '~/client/util/use-input-validator';
+import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '~/components/Common/SubmittableInput';
+import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
+import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
+import { useSWRMUTxPageInfo } from '~/stores/page';
+
+import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
+import type { TreeItemToolProps } from '../../TreeItem';
+
+
+type UsePageItemControl = {
+  Control: FC<TreeItemToolProps>,
+  RenameInput: FC<TreeItemToolProps>,
+  showRenameInput: boolean,
+}
+
+export const usePageItemControl = (): UsePageItemControl => {
+  const { t } = useTranslation();
+
+  const [showRenameInput, setShowRenameInput] = useState(false);
+
+
+  const Control: FC<TreeItemToolProps> = (props) => {
+    const {
+      itemNode,
+      isEnableActions,
+      isReadOnlyUser,
+      onClickDuplicateMenuItem, onClickDeleteMenuItem,
+    } = props;
+    const { page } = itemNode;
+
+    const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
+    const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(page._id ?? null);
+
+    const bookmarkMenuItemClickHandler = useCallback(async(_pageId: string, _newValue: boolean): Promise<void> => {
+      const bookmarkOperation = _newValue ? bookmark : unbookmark;
+      await bookmarkOperation(_pageId);
+      mutateCurrentUserBookmarks();
+      mutatePageInfo();
+    }, [mutateCurrentUserBookmarks, mutatePageInfo]);
+
+    const duplicateMenuItemClickHandler = useCallback((): void => {
+      if (onClickDuplicateMenuItem == null) {
+        return;
+      }
+
+      const { _id: pageId, path } = page;
+
+      if (pageId == null || path == null) {
+        throw Error('Any of _id and path must not be null.');
+      }
+
+      const pageToDuplicate = { pageId, path };
+
+      onClickDuplicateMenuItem(pageToDuplicate);
+    }, [onClickDuplicateMenuItem, page]);
+
+    const renameMenuItemClickHandler = useCallback(() => {
+      setShowRenameInput(true);
+    }, []);
+
+    const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
+      if (onClickDeleteMenuItem == null) {
+        return;
+      }
+
+      if (page._id == null || page.path == null) {
+        throw Error('_id and path must not be null.');
+      }
+
+      const pageToDelete: IPageToDeleteWithMeta = {
+        data: {
+          _id: page._id,
+          revision: page.revision as string,
+          path: page.path,
+        },
+        meta: pageInfo,
+      };
+
+      onClickDeleteMenuItem(pageToDelete);
+    }, [onClickDeleteMenuItem, page]);
+
+    const pathRecoveryMenuItemClickHandler = async(pageId: string): Promise<void> => {
+      try {
+        await resumeRenameOperation(pageId);
+        toastSuccess(t('page_operation.paths_recovered'));
+      }
+      catch {
+        toastError(t('page_operation.path_recovery_failed'));
+      }
+    };
+
+    return (
+      <NotAvailableForGuest>
+        <div className="grw-pagetree-control d-flex">
+          <PageItemControl
+            pageId={page._id}
+            isEnableActions={isEnableActions}
+            isReadOnlyUser={isReadOnlyUser}
+            onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+            onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+            onClickRenameMenuItem={renameMenuItemClickHandler}
+            onClickDeleteMenuItem={deleteMenuItemClickHandler}
+            onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
+            isInstantRename
+            // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
+            operationProcessData={page.processData}
+          >
+            {/* pass the color property to reactstrap dropdownToggle props. https://6-4-0--reactstrap.netlify.app/components/dropdowns/  */}
+            <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 mr-1">
+              <span id="option-button-in-page-tree" className="material-symbols-outlined p-1">more_vert</span>
+            </DropdownToggle>
+          </PageItemControl>
+        </div>
+      </NotAvailableForGuest>
+    );
+  };
+
+
+  const RenameInput: FC<TreeItemToolProps> = (props) => {
+    const { itemNode, onRenamed } = props;
+    const { page } = itemNode;
+
+    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 cancel = useCallback(() => {
+      setValidationResult(undefined);
+      setShowRenameInput(false);
+    }, []);
+
+    const rename = useCallback(async(inputText) => {
+      if (inputText.trim() === '') {
+        return cancel();
+      }
+
+      const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(page.path ?? ''));
+      const newPagePath = nodePath.resolve(parentPath, inputText);
+
+      if (newPagePath === page.path) {
+        setValidationResult(undefined);
+        setShowRenameInput(false);
+        return;
+      }
+
+      try {
+        await apiv3Put('/pages/rename', {
+          pageId: page._id,
+          revisionId: page.revision,
+          newPagePath,
+        });
+
+        onRenamed?.(page.path, newPagePath);
+        setShowRenameInput(false);
+
+        toastSuccess(t('renamed_pages', { path: page.path }));
+      }
+      catch (err) {
+        toastError(err);
+      }
+      finally {
+        setValidationResult(undefined);
+      }
+
+    }, [cancel, onRenamed, page._id, page.path, page.revision]);
+
+
+    if (!showRenameInput) {
+      return <></>;
+    }
+
+    const isInvalid = validationResult != null;
+
+    const maxWidth = parentRect != null
+      ? getAdjustedMaxWidthForAutosizeInput(parentRect.width, 'sm', validationResult != null ? false : undefined)
+      : undefined;
+
+    return (
+      <div ref={parentRef} className="flex-fill">
+        <AutosizeSubmittableInput
+          value={nodePath.basename(page.path ?? '')}
+          inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
+          inputStyle={{ maxWidth }}
+          placeholder={t('Input page name')}
+          aria-describedby={isInvalid ? 'rename-feedback' : undefined}
+          onChange={changeHandlerDebounced}
+          onSubmit={rename}
+          onCancel={cancel}
+          autoFocus
+        />
+        { isInvalid && (
+          <div id="rename-feedback" className="invalid-feedback d-block my-1">
+            {validationResult.message}
+          </div>
+        ) }
+      </div>
+    );
+  };
+
+
+  return {
+    Control,
+    RenameInput,
+    showRenameInput,
+  };
+
+};

+ 17 - 13
apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx

@@ -8,9 +8,6 @@ import {
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'react-i18next';
-import {
-  DropdownItem, DropdownMenu, DropdownToggle, UncontrolledButtonDropdown,
-} from 'reactstrap';
 
 import { useKeywordManager } from '~/client/services/search-operation';
 import { PagePathHierarchicalLink } from '~/components/Common/PagePathHierarchicalLink';
@@ -182,13 +179,20 @@ export const RecentChangesHeader = ({
     <>
       <SidebarHeaderReloadButton onClick={() => mutate()} />
 
-      <UncontrolledButtonDropdown className="me-1">
-        <DropdownToggle color="transparent" className="p-0 border-0">
+      <div className="me-1">
+        <button
+          color="transparent"
+          className="btn p-0 border-0"
+          type="button"
+          data-bs-toggle="dropdown"
+          data-bs-auto-close="outside"
+          aria-expanded="false"
+        >
           <span className="material-symbols-outlined">more_horiz</span>
-        </DropdownToggle>
+        </button>
 
-        <DropdownMenu container="body">
-          <DropdownItem onClick={changeSizeHandler}>
+        <ul className="dropdown-menu">
+          <li className="dropdown-item" onClick={changeSizeHandler}>
             <div className={`${styles['grw-recent-changes-resize-button']} form-check form-switch mb-0`}>
               <input
                 id="recentChangesResize"
@@ -201,9 +205,9 @@ export const RecentChangesHeader = ({
                 {isSmall ? t('sidebar_header.size_s') : t('sidebar_header.size_l')}
               </label>
             </div>
-          </DropdownItem>
+          </li>
 
-          <DropdownItem onClick={onWipPageShownChange}>
+          <li className="dropdown-item" onClick={onWipPageShownChange}>
             <div className="form-check form-switch mb-0">
               <input
                 id="wipPageVisibility"
@@ -216,9 +220,9 @@ export const RecentChangesHeader = ({
                 {t('sidebar_header.show_wip_page')}
               </label>
             </div>
-          </DropdownItem>
-        </DropdownMenu>
-      </UncontrolledButtonDropdown>
+          </li>
+        </ul>
+      </div>
     </>
   );
 };

+ 7 - 0
apps/app/src/components/Sidebar/Sidebar.tsx

@@ -1,6 +1,7 @@
 import React, {
   type FC,
   memo, useCallback, useEffect, useState,
+  useRef,
 } from 'react';
 
 import dynamic from 'next/dynamic';
@@ -13,6 +14,7 @@ import {
   useCurrentProductNavWidth,
   usePreferCollapsedMode,
   useSidebarMode,
+  useSidebarScrollerRef,
 } from '~/stores/ui';
 
 import { DrawerToggler } from '../Common/DrawerToggler';
@@ -109,6 +111,10 @@ const CollapsibleContainer = memo((props: CollapsibleContainerProps): JSX.Elemen
   const { data: currentProductNavWidth } = useCurrentProductNavWidth();
   const { data: isCollapsedContentsOpened, mutate: mutateCollapsedContentsOpened } = useCollapsedContentsOpened();
 
+  const sidebarScrollerRef = useRef<HTMLDivElement>(null);
+  const { mutate: mutateSidebarScroller } = useSidebarScrollerRef();
+  mutateSidebarScroller(sidebarScrollerRef);
+
 
   // open menu when collapsed mode
   const primaryItemHoverHandler = useCallback(() => {
@@ -138,6 +144,7 @@ const CollapsibleContainer = memo((props: CollapsibleContainerProps): JSX.Elemen
     <div className={`flex-expand-horiz ${className}`} onMouseLeave={mouseLeaveHandler}>
       <Nav onPrimaryItemHover={primaryItemHoverHandler} />
       <div
+        ref={sidebarScrollerRef}
         className={`sidebar-contents-container flex-grow-1 overflow-y-auto overflow-x-hidden ${closedClass} ${openedClass}`}
         style={{ width: collapsibleContentsWidth }}
       >

+ 3 - 2
apps/app/src/components/Sidebar/SidebarNav/SecondaryItems.tsx

@@ -4,7 +4,7 @@ import { memo } from 'react';
 import dynamic from 'next/dynamic';
 import Link from 'next/link';
 
-import { useGrowiCloudUri, useIsAdmin } from '~/stores/context';
+import { useIsGuestUser, useGrowiCloudUri, useIsAdmin } from '~/stores/context';
 
 import { SkeletonItem } from './SkeletonItem';
 
@@ -43,10 +43,11 @@ export const SecondaryItems: FC = memo(() => {
 
   const { data: isAdmin } = useIsAdmin();
   const { data: growiCloudUri } = useGrowiCloudUri();
+  const { data: isGuestUser } = useIsGuestUser();
 
   return (
     <div className={styles['grw-secondary-items']}>
-      <PersonalDropdown />
+      {!isGuestUser && <PersonalDropdown />}
       <SecondaryItem label="Help" iconName="help" href={growiCloudUri != null ? 'https://growi.cloud/help/' : 'https://docs.growi.org'} isBlank />
       {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
       <SecondaryItem label="Trash" href="/trash" iconName="delete" />

+ 4 - 0
apps/app/src/components/Skeleton.module.scss

@@ -0,0 +1,4 @@
+.grw-skeleton {
+  --bs-list-group-color: rgba(var(--bs-tertiary-color-rgb), 0.2);
+  background-color: var(--bs-list-group-color);
+}

+ 5 - 2
apps/app/src/components/Skeleton.tsx

@@ -1,4 +1,7 @@
-import React from 'react';
+import styles from './Skeleton.module.scss';
+
+const moduleClass = styles['grw-skeleton'] ?? '';
+
 
 type SkeletonProps = {
   additionalClass?: string,
@@ -12,7 +15,7 @@ export const Skeleton = (props: SkeletonProps): JSX.Element => {
 
   return (
     <div className={`${additionalClass ?? ''}`}>
-      <div className={`grw-skeleton h-100 w-100 ${roundedPill ? 'rounded-pill' : ''}`}></div>
+      <div className={`grw-skeleton ${moduleClass} h-100 w-100 ${roundedPill ? 'rounded-pill' : ''}`}></div>
     </div>
   );
 };

+ 1 - 1
apps/app/src/components/TreeItem/ItemNode.ts

@@ -1,4 +1,4 @@
-import { IPageForItem } from '../../interfaces/page';
+import type { IPageForItem } from '../../interfaces/page';
 
 export class ItemNode {
 

+ 2 - 2
apps/app/src/components/TreeItem/NewPageInput/NewPageCreateButton.tsx

@@ -24,10 +24,10 @@ export const NewPageCreateButton: FC<NewPageCreateButtonProps> = (props) => {
             <button
               id="page-create-button-in-page-tree"
               type="button"
-              className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
+              className="border-0 rounded btn btn-page-item-control p-0"
               onClick={onClick}
             >
-              <span className="material-symbols-outlined d-block p-0">add_circle</span>
+              <span className="material-symbols-outlined p-0">add_circle</span>
             </button>
           </NotAvailableForReadOnlyUser>
         </NotAvailableForGuest>

+ 6 - 0
apps/app/src/components/TreeItem/NewPageInput/NewPageInput.module.scss

@@ -0,0 +1,6 @@
+@use '../tree-item-variables';
+
+.new-page-input-container {
+  width: calc(100% - tree-item-variables.$btn-triangle-min-width);
+  padding-left: 24px;
+}

+ 0 - 79
apps/app/src/components/TreeItem/NewPageInput/NewPageInput.tsx

@@ -1,79 +0,0 @@
-import React, {
-  type FC, useCallback,
-} from 'react';
-
-import nodePath from 'path';
-
-import { pathUtils, pagePathUtils } from '@growi/core/dist/utils';
-import { useTranslation } from 'next-i18next';
-
-import { ValidationTarget } from '~/client/util/input-validator';
-import { toastWarning, toastError, toastSuccess } from '~/client/util/toastr';
-import ClosableTextInput from '~/components/Common/ClosableTextInput';
-import type { IPageForItem } from '~/interfaces/page';
-
-import { NotDraggableForClosableTextInput } from '../NotDraggableForClosableTextInput';
-
-type Props = {
-  page: IPageForItem,
-  isEnableActions: boolean,
-  onSubmit?: (newPagePath: string) => Promise<void>,
-  onSubmittionFailed?: () => void,
-  onCanceled?: () => void,
-};
-
-export const NewPageInput: FC<Props> = (props) => {
-  const { t } = useTranslation();
-
-  const {
-    page, isEnableActions,
-    onSubmit, onSubmittionFailed,
-    onCanceled,
-  } = props;
-
-  const cancel = useCallback(() => {
-    onCanceled?.();
-  }, [onCanceled]);
-
-  const create = useCallback(async(inputText) => {
-    if (inputText.trim() === '') {
-      return cancel();
-    }
-
-    const parentPath = pathUtils.addTrailingSlash(page.path as string);
-    const newPagePath = nodePath.resolve(parentPath, inputText);
-    const isCreatable = pagePathUtils.isCreatablePage(newPagePath);
-
-    if (!isCreatable) {
-      toastWarning(t('you_can_not_create_page_with_this_name'));
-      return;
-    }
-
-    try {
-      await onSubmit?.(newPagePath);
-      toastSuccess(t('successfully_saved_the_page'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-    finally {
-      onSubmittionFailed?.();
-    }
-  }, [cancel, onSubmit, onSubmittionFailed, page.path, t]);
-
-  return (
-    <>
-      {isEnableActions && (
-        <NotDraggableForClosableTextInput>
-          <ClosableTextInput
-            placeholder={t('Input page name')}
-            onPressEnter={create}
-            onPressEscape={cancel}
-            onBlur={create}
-            validationTarget={ValidationTarget.PAGE}
-          />
-        </NotDraggableForClosableTextInput>
-      )}
-    </>
-  );
-};

+ 102 - 42
apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx

@@ -1,8 +1,21 @@
-import React, { useState, type FC, useCallback } from 'react';
+import type { ChangeEvent } from 'react';
+import React, {
+  useState, type FC, useCallback, useRef,
+} from 'react';
+
+import nodePath from 'path';
 
 import { Origin } from '@growi/core';
+import { pathUtils, pagePathUtils } from '@growi/core/dist/utils';
+import { useRect } from '@growi/ui/dist/utils';
+import { useTranslation } from 'next-i18next';
+import { debounce } from 'throttle-debounce';
 
 import { createPage } from '~/client/services/create-page';
+import { toastWarning, toastError, toastSuccess } from '~/client/util/toastr';
+import type { InputValidationResult } from '~/client/util/use-input-validator';
+import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
+import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '~/components/Common/SubmittableInput';
 import { mutatePageTree } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 
@@ -10,7 +23,9 @@ import { shouldCreateWipPage } from '../../../utils/should-create-wip-page';
 import type { TreeItemToolProps } from '../interfaces';
 
 import { NewPageCreateButton } from './NewPageCreateButton';
-import { NewPageInput } from './NewPageInput';
+
+
+import newPageInputStyles from './NewPageInput.module.scss';
 
 
 type UseNewPageInput = {
@@ -24,26 +39,15 @@ export const useNewPageInput = (): UseNewPageInput => {
   const [showInput, setShowInput] = useState(false);
   const [isProcessingSubmission, setProcessingSubmission] = useState(false);
 
-  const { getDescCount } = usePageTreeDescCountMap();
-
   const CreateButton: FC<TreeItemToolProps> = (props) => {
 
     const { itemNode, stateHandlers } = props;
-    const { page, children } = itemNode;
-
-    // descendantCount
-    const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
-
-    const isChildrenLoaded = children?.length > 0;
-    const hasDescendants = descendantCount > 0 || isChildrenLoaded;
+    const { page } = itemNode;
 
     const onClick = useCallback(() => {
       setShowInput(true);
-
-      if (hasDescendants) {
-        stateHandlers?.setIsOpen(true);
-      }
-    }, [hasDescendants, stateHandlers]);
+      stateHandlers?.setIsOpen(true);
+    }, [stateHandlers]);
 
     return (
       <NewPageCreateButton
@@ -55,7 +59,9 @@ export const useNewPageInput = (): UseNewPageInput => {
 
   const Input: FC<TreeItemToolProps> = (props) => {
 
-    const { itemNode, stateHandlers } = props;
+    const { t } = useTranslation();
+
+    const { itemNode, stateHandlers, isEnableActions } = props;
     const { page, children } = itemNode;
 
     const { getDescCount } = usePageTreeDescCountMap();
@@ -64,41 +70,95 @@ export const useNewPageInput = (): UseNewPageInput => {
     const isChildrenLoaded = children?.length > 0;
     const hasDescendants = descendantCount > 0 || isChildrenLoaded;
 
-    const submitHandler = useCallback(async(newPagePath: string) => {
+    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 cancel = useCallback(() => {
+      setValidationResult(undefined);
+      setShowInput(false);
+    }, []);
+
+    const create = useCallback(async(inputText) => {
+      if (inputText.trim() === '') {
+        return cancel();
+      }
+
+      const parentPath = pathUtils.addTrailingSlash(page.path as string);
+      const newPagePath = nodePath.resolve(parentPath, inputText);
+      const isCreatable = pagePathUtils.isCreatablePage(newPagePath);
+
+      if (!isCreatable) {
+        toastWarning(t('you_can_not_create_page_with_this_name'));
+        return;
+      }
+
       setProcessingSubmission(true);
 
       setShowInput(false);
 
-      await createPage({
-        path: newPagePath,
-        body: undefined,
-        // keep grant info undefined to inherit from parent
-        grant: undefined,
-        grantUserGroupIds: undefined,
-        origin: Origin.View,
-        wip: shouldCreateWipPage(newPagePath),
-      });
+      try {
+        await createPage({
+          path: newPagePath,
+          body: undefined,
+          // keep grant info undefined to inherit from parent
+          grant: undefined,
+          grantUserGroupIds: undefined,
+          origin: Origin.View,
+          wip: shouldCreateWipPage(newPagePath),
+        });
 
-      mutatePageTree();
+        mutatePageTree();
 
-      if (!hasDescendants) {
-        stateHandlers?.setIsOpen(true);
+        if (!hasDescendants) {
+          stateHandlers?.setIsOpen(true);
+        }
+
+        toastSuccess(t('successfully_saved_the_page'));
+      }
+      catch (err) {
+        toastError(err);
       }
-    }, [hasDescendants, stateHandlers]);
+      finally {
+        setProcessingSubmission(false);
+      }
+    }, [cancel, hasDescendants, page.path, stateHandlers, t]);
 
-    const submittionFailedHandler = useCallback(() => {
-      setProcessingSubmission(false);
-    }, []);
+    const inputContainerClass = newPageInputStyles['new-page-input-container'] ?? '';
+    const isInvalid = validationResult != null;
+
+    const maxWidth = parentRect != null
+      ? getAdjustedMaxWidthForAutosizeInput(parentRect.width, 'sm', validationResult != null ? false : undefined)
+      : undefined;
 
-    return showInput
+    return isEnableActions && showInput
       ? (
-        <NewPageInput
-          page={page}
-          isEnableActions={props.isEnableActions}
-          onSubmit={submitHandler}
-          onSubmittionFailed={submittionFailedHandler}
-          onCanceled={() => setShowInput(false)}
-        />
+        <div ref={parentRef} className={inputContainerClass}>
+          <AutosizeSubmittableInput
+            inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
+            inputStyle={{ maxWidth }}
+            placeholder={t('Input page name')}
+            aria-describedby={isInvalid ? 'new-page-input-feedback' : undefined}
+            onChange={changeHandlerDebounced}
+            onSubmit={create}
+            onCancel={cancel}
+            autoFocus
+          />
+          { isInvalid && (
+            <div id="new-page-input" className="invalid-feedback d-block my-1">
+              {validationResult.message}
+            </div>
+          ) }
+        </div>
       )
       : <></>;
   };

+ 0 - 261
apps/app/src/components/TreeItem/SimpleItem.tsx

@@ -1,261 +0,0 @@
-import React, {
-  useCallback, useState, useEffect,
-  type FC, type RefObject, type RefCallback, type MouseEvent,
-} from 'react';
-
-import nodePath from 'path';
-
-import type { Nullable } from '@growi/core';
-import { LoadingSpinner } from '@growi/ui/dist/components';
-import { useTranslation } from 'next-i18next';
-import { UncontrolledTooltip } from 'reactstrap';
-
-import type { IPageForItem } from '~/interfaces/page';
-import { useSWRxPageChildren } from '~/stores/page-listing';
-import { usePageTreeDescCountMap } from '~/stores/ui';
-import { shouldRecoverPagePaths } from '~/utils/page-operation';
-
-import CountBadge from '../Common/CountBadge';
-
-import { ItemNode } from './ItemNode';
-import { useNewPageInput } from './NewPageInput';
-import type { TreeItemProps, TreeItemToolProps } from './interfaces';
-
-
-// Utility to mark target
-const markTarget = (children: ItemNode[], targetPathOrId?: Nullable<string>): void => {
-  if (targetPathOrId == null) {
-    return;
-  }
-
-  children.forEach((node) => {
-    if (node.page._id === targetPathOrId || node.page.path === targetPathOrId) {
-      node.page.isTarget = true;
-    }
-    else {
-      node.page.isTarget = false;
-    }
-    return node;
-  });
-};
-
-
-const SimpleItemContent = ({ page }: { page: IPageForItem }) => {
-  const { t } = useTranslation();
-
-  const pageName = nodePath.basename(page.path ?? '') || '/';
-
-  const shouldShowAttentionIcon = page.processData != null ? shouldRecoverPagePaths(page.processData) : false;
-
-  return (
-    <div
-      className="flex-grow-1 d-flex align-items-center pe-none"
-      style={{ minWidth: 0 }}
-    >
-      {shouldShowAttentionIcon && (
-        <>
-          <span id="path-recovery" className="material-symbols-outlined mr-2 text-warning">warning</span>
-          <UncontrolledTooltip placement="top" target="path-recovery" fade={false}>
-            {t('tooltip.operation.attention.rename')}
-          </UncontrolledTooltip>
-        </>
-      )}
-      {page != null && page.path != null && page._id != null && (
-        <div className="grw-pagetree-title-anchor flex-grow-1">
-          <div className="d-flex align-items-center">
-            <span className={`text-truncate me-1 ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{pageName}</span>
-            { page.wip && (
-              <span className="wip-page-badge badge rounded-pill me-1 text-bg-secondary">WIP</span>
-            )}
-          </div>
-        </div>
-      )}
-    </div>
-  );
-};
-
-export const SimpleItemTool: FC<TreeItemToolProps> = (props) => {
-  const { getDescCount } = usePageTreeDescCountMap();
-
-  const { page } = props.itemNode;
-
-  const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
-
-  return (
-    <>
-      {descendantCount > 0 && (
-        <div className="grw-pagetree-count-wrapper">
-          <CountBadge count={descendantCount} />
-        </div>
-      )}
-    </>
-  );
-};
-
-type SimpleItemProps = TreeItemProps & {
-  itemRef?: RefObject<any> | RefCallback<any>,
-}
-
-export const SimpleItem: FC<SimpleItemProps> = (props) => {
-  const {
-    itemNode, targetPathOrId, isOpen: _isOpen = false,
-    onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, isEnableActions, isReadOnlyUser, isWipPageShown = true,
-    itemRef, itemClass, mainClassName,
-  } = props;
-
-  const { page, children } = itemNode;
-
-  const { isProcessingSubmission } = useNewPageInput();
-
-  const [currentChildren, setCurrentChildren] = useState(children);
-  const [isOpen, setIsOpen] = useState(_isOpen);
-
-  const { data } = useSWRxPageChildren(isOpen ? page._id : null);
-
-
-  const itemClickHandler = useCallback((e: MouseEvent) => {
-    // DO NOT handle the event when e.currentTarget and e.target is different
-    if (e.target !== e.currentTarget) {
-      return;
-    }
-
-    onClick?.(page);
-
-  }, [onClick, page]);
-
-
-  // descendantCount
-  const { getDescCount } = usePageTreeDescCountMap();
-  const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
-
-  // hasDescendants flag
-  const isChildrenLoaded = currentChildren?.length > 0;
-  const hasDescendants = descendantCount > 0 || isChildrenLoaded;
-
-  const hasChildren = useCallback((): boolean => {
-    return currentChildren != null && currentChildren.length > 0;
-  }, [currentChildren]);
-
-  const onClickLoadChildren = useCallback(() => {
-    setIsOpen(!isOpen);
-  }, [isOpen]);
-
-  // didMount
-  useEffect(() => {
-    if (hasChildren()) setIsOpen(true);
-  }, [hasChildren]);
-
-  /*
-   * Make sure itemNode.children and currentChildren are synced
-   */
-  useEffect(() => {
-    if (children.length > currentChildren.length) {
-      markTarget(children, targetPathOrId);
-      setCurrentChildren(children);
-    }
-  }, [children, currentChildren.length, targetPathOrId]);
-
-  /*
-   * When swr fetch succeeded
-   */
-  useEffect(() => {
-    if (isOpen && data != null) {
-      const newChildren = ItemNode.generateNodesFromPages(data.children);
-      markTarget(newChildren, targetPathOrId);
-      setCurrentChildren(newChildren);
-    }
-  }, [data, isOpen, targetPathOrId]);
-
-  const ItemClassFixed = itemClass ?? SimpleItem;
-
-  const EndComponents = props.customEndComponents ?? [SimpleItemTool];
-
-  const baseProps: Omit<TreeItemProps, 'itemNode'> = {
-    isEnableActions,
-    isReadOnlyUser,
-    isOpen: false,
-    isWipPageShown,
-    targetPathOrId,
-    onRenamed,
-    onClickDuplicateMenuItem,
-    onClickDeleteMenuItem,
-  };
-
-  const toolProps: TreeItemToolProps = {
-    ...baseProps,
-    itemNode,
-  };
-
-  const CustomNextComponents = props.customNextComponents;
-
-  if (!isWipPageShown && page.wip) {
-    return <></>;
-  }
-
-  return (
-    <div
-      id={`pagetree-item-${page._id}`}
-      data-testid="grw-pagetree-item-container"
-      className={`grw-pagetree-item-container ${mainClassName}`}
-    >
-      <li
-        ref={itemRef}
-        role="button"
-        className={`list-group-item border-0 py-0 pr-3 d-flex align-items-center text-muted rounded-1 ${page.isTarget ? 'active' : 'list-group-item-action'}`}
-        id={page.isTarget ? 'grw-pagetree-current-page-item' : `grw-pagetree-list-${page._id}`}
-        onClick={itemClickHandler}
-      >
-
-        <div className="grw-triangle-container d-flex justify-content-center">
-          {hasDescendants && (
-            <button
-              type="button"
-              className={`grw-pagetree-triangle-btn btn p-0 ${isOpen ? 'grw-pagetree-open' : ''}`}
-              onClick={onClickLoadChildren}
-            >
-              <div className="d-flex justify-content-center">
-                <span className="material-symbols-outlined">arrow_right</span>
-              </div>
-            </button>
-          )}
-        </div>
-
-        <SimpleItemContent page={page} />
-
-        {EndComponents.map((EndComponent, index) => (
-          // eslint-disable-next-line react/no-array-index-key
-          <EndComponent key={index} {...toolProps} />
-        ))}
-
-      </li>
-
-      {CustomNextComponents?.map((UnderItemContent, index) => (
-        // eslint-disable-next-line react/no-array-index-key
-        <UnderItemContent key={index} {...toolProps} />
-      ))}
-
-      {
-        isOpen && hasChildren() && currentChildren.map((node, index) => {
-          const itemProps = {
-            ...baseProps,
-            itemNode: node,
-            itemClass,
-            mainClassName,
-            onClick,
-          };
-
-          return (
-            <div key={node.page._id} className="grw-pagetree-item-children">
-              <ItemClassFixed {...itemProps} />
-              {isProcessingSubmission && (currentChildren.length - 1 === index) && (
-                <div className="text-muted text-center">
-                  <LoadingSpinner className="mr-1" />
-                </div>
-              )}
-            </div>
-          );
-        })
-      }
-    </div>
-  );
-};

+ 7 - 0
apps/app/src/components/TreeItem/SimpleItemContent.module.scss

@@ -0,0 +1,7 @@
+.simple-item-content :global {
+  .grw-page-title-anchor {
+    width: 100%;
+    overflow: hidden;
+    text-decoration: none;
+  }
+}

+ 46 - 0
apps/app/src/components/TreeItem/SimpleItemContent.tsx

@@ -0,0 +1,46 @@
+import nodePath from 'path';
+
+import { useTranslation } from 'next-i18next';
+import { UncontrolledTooltip } from 'reactstrap';
+
+import type { IPageForItem } from '~/interfaces/page';
+import { shouldRecoverPagePaths } from '~/utils/page-operation';
+
+import styles from './SimpleItemContent.module.scss';
+
+const moduleClass = styles['simple-item-content'] ?? '';
+
+
+export const SimpleItemContent = ({ page }: { page: IPageForItem }): JSX.Element => {
+  const { t } = useTranslation();
+
+  const pageName = nodePath.basename(page.path ?? '') || '/';
+
+  const shouldShowAttentionIcon = page.processData != null ? shouldRecoverPagePaths(page.processData) : false;
+
+  return (
+    <div
+      className={`${moduleClass} flex-grow-1 d-flex align-items-center pe-none`}
+      style={{ minWidth: 0 }}
+    >
+      {shouldShowAttentionIcon && (
+        <>
+          <span id="path-recovery" className="material-symbols-outlined mr-2 text-warning">warning</span>
+          <UncontrolledTooltip placement="top" target="path-recovery" fade={false}>
+            {t('tooltip.operation.attention.rename')}
+          </UncontrolledTooltip>
+        </>
+      )}
+      {page != null && page.path != null && page._id != null && (
+        <div className="grw-page-title-anchor flex-grow-1">
+          <div className="d-flex align-items-center">
+            <span className={`text-truncate me-1 ${page.isEmpty && 'opacity-75'}`}>{pageName}</span>
+            { page.wip && (
+              <span className="wip-page-badge badge rounded-pill me-1 text-bg-secondary">WIP</span>
+            )}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+};

+ 36 - 0
apps/app/src/components/TreeItem/TreeItemLayout.module.scss

@@ -0,0 +1,36 @@
+@use './tree-item-variables';
+
+// show / hide on hover
+.tree-item-layout {
+  :global {
+    .list-group-item {
+      &:hover {
+        .d-hover-none {
+          display: none !important;
+        }
+        .d-hover-flex {
+          display: flex !important;
+        }
+      }
+    }
+  }
+}
+
+// btn-triangle
+.tree-item-layout :global {
+  .btn-triangle-container {
+    min-width: tree-item-variables.$btn-triangle-min-width;
+  }
+
+  .btn-triangle {
+    --bs-btn-color: var(--bs-tertiary-color);
+
+    border: 0;
+    transition: all 0.2s ease-out;
+    transform: rotate(0deg);
+
+    &.open {
+      transform: rotate(90deg);
+    }
+  }
+}

+ 235 - 0
apps/app/src/components/TreeItem/TreeItemLayout.tsx

@@ -0,0 +1,235 @@
+import React, {
+  useCallback, useState, useEffect,
+  type FC, type RefObject, type RefCallback, type MouseEvent,
+} from 'react';
+
+import type { Nullable } from '@growi/core';
+
+import { useSWRxPageChildren } from '~/stores/page-listing';
+import { usePageTreeDescCountMap } from '~/stores/ui';
+
+import { ItemNode } from './ItemNode';
+import { SimpleItemContent } from './SimpleItemContent';
+import type { TreeItemProps, TreeItemToolProps } from './interfaces';
+
+
+import styles from './TreeItemLayout.module.scss';
+
+const moduleClass = styles['tree-item-layout'] ?? '';
+
+
+// Utility to mark target
+const markTarget = (children: ItemNode[], targetPathOrId?: Nullable<string>): void => {
+  if (targetPathOrId == null) {
+    return;
+  }
+
+  children.forEach((node) => {
+    if (node.page._id === targetPathOrId || node.page.path === targetPathOrId) {
+      node.page.isTarget = true;
+    }
+    else {
+      node.page.isTarget = false;
+    }
+    return node;
+  });
+};
+
+
+type TreeItemLayoutProps = TreeItemProps & {
+  className?: string,
+  itemRef?: RefObject<any> | RefCallback<any>,
+  indentSize?: number,
+}
+
+export const TreeItemLayout: FC<TreeItemLayoutProps> = (props) => {
+  const {
+    className, itemClassName,
+    indentSize = 10,
+    itemLevel: baseItemLevel = 1,
+    itemNode, targetPathOrId, isOpen: _isOpen = false,
+    onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, isEnableActions, isReadOnlyUser, isWipPageShown = true,
+    itemRef, itemClass,
+    showAlternativeContent,
+  } = props;
+
+  const { page, children } = itemNode;
+
+  const [currentChildren, setCurrentChildren] = useState(children);
+  const [isOpen, setIsOpen] = useState(_isOpen);
+
+  const { data } = useSWRxPageChildren(isOpen ? page._id : null);
+
+
+  const itemClickHandler = useCallback((e: MouseEvent) => {
+    // DO NOT handle the event when e.currentTarget and e.target is different
+    if (e.target !== e.currentTarget) {
+      return;
+    }
+
+    onClick?.(page);
+
+  }, [onClick, page]);
+
+
+  // descendantCount
+  const { getDescCount } = usePageTreeDescCountMap();
+  const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
+
+  // hasDescendants flag
+  const isChildrenLoaded = currentChildren?.length > 0;
+  const hasDescendants = descendantCount > 0 || isChildrenLoaded;
+
+  const hasChildren = useCallback((): boolean => {
+    return currentChildren != null && currentChildren.length > 0;
+  }, [currentChildren]);
+
+  const onClickLoadChildren = useCallback(() => {
+    setIsOpen(!isOpen);
+  }, [isOpen]);
+
+  // didMount
+  useEffect(() => {
+    if (hasChildren()) setIsOpen(true);
+  }, [hasChildren]);
+
+  /*
+   * Make sure itemNode.children and currentChildren are synced
+   */
+  useEffect(() => {
+    if (children.length > currentChildren.length) {
+      markTarget(children, targetPathOrId);
+      setCurrentChildren(children);
+    }
+  }, [children, currentChildren.length, targetPathOrId]);
+
+  /*
+   * When swr fetch succeeded
+   */
+  useEffect(() => {
+    if (isOpen && data != null) {
+      const newChildren = ItemNode.generateNodesFromPages(data.children);
+      markTarget(newChildren, targetPathOrId);
+      setCurrentChildren(newChildren);
+    }
+  }, [data, isOpen, targetPathOrId]);
+
+  const ItemClassFixed = itemClass ?? TreeItemLayout;
+
+  const baseProps: Omit<TreeItemProps, 'itemLevel' | 'itemNode'> = {
+    isEnableActions,
+    isReadOnlyUser,
+    isOpen: false,
+    isWipPageShown,
+    targetPathOrId,
+    onRenamed,
+    onClickDuplicateMenuItem,
+    onClickDeleteMenuItem,
+  };
+
+  const toolProps: TreeItemToolProps = {
+    ...baseProps,
+    itemLevel: baseItemLevel,
+    itemNode,
+    stateHandlers: {
+      setIsOpen,
+    },
+  };
+
+  const EndComponents = props.customEndComponents;
+  const HoveredEndComponents = props.customHoveredEndComponents;
+  const HeadObChildrenComponents = props.customHeadOfChildrenComponents;
+  const AlternativeComponents = props.customAlternativeComponents;
+
+  if (!isWipPageShown && page.wip) {
+    return <></>;
+  }
+
+  return (
+    <div
+      id={`tree-item-layout-${page._id}`}
+      data-testid="grw-pagetree-item-container"
+      className={`${moduleClass} ${className} level-${baseItemLevel}`}
+      style={{ paddingLeft: `${baseItemLevel > 1 ? indentSize : 0}px` }}
+    >
+      <li
+        ref={itemRef}
+        role="button"
+        className={`list-group-item ${itemClassName}
+          border-0 py-0 ps-0 d-flex align-items-center rounded-1
+          ${page.isTarget ? 'active' : 'list-group-item-action'}`}
+        id={page.isTarget ? 'grw-pagetree-current-page-item' : `grw-pagetree-list-${page._id}`}
+        onClick={itemClickHandler}
+      >
+
+        <div className="btn-triangle-container d-flex justify-content-center">
+          {hasDescendants && (
+            <button
+              type="button"
+              className={`btn btn-triangle p-0 ${isOpen ? 'open' : ''}`}
+              onClick={onClickLoadChildren}
+            >
+              <div className="d-flex justify-content-center">
+                <span className="material-symbols-outlined">arrow_right</span>
+              </div>
+            </button>
+          )}
+        </div>
+
+        { showAlternativeContent && AlternativeComponents != null
+          ? (
+            AlternativeComponents.map((AlternativeContent, index) => (
+              // eslint-disable-next-line react/no-array-index-key
+              <AlternativeContent key={index} {...toolProps} />
+            ))
+          )
+          : (
+            <>
+              <SimpleItemContent page={page} />
+              <div className="d-hover-none">
+                {EndComponents?.map((EndComponent, index) => (
+                  // eslint-disable-next-line react/no-array-index-key
+                  <EndComponent key={index} {...toolProps} />
+                ))}
+              </div>
+              <div className="d-none d-hover-flex">
+                {HoveredEndComponents?.map((HoveredEndContent, index) => (
+                  // eslint-disable-next-line react/no-array-index-key
+                  <HoveredEndContent key={index} {...toolProps} />
+                ))}
+              </div>
+            </>
+          )
+        }
+
+      </li>
+
+      { isOpen && (
+        <div className={`tree-item-layout-children level-${baseItemLevel + 1}`}>
+
+          {HeadObChildrenComponents?.map((HeadObChildrenContents, index) => (
+            // eslint-disable-next-line react/no-array-index-key
+            <HeadObChildrenContents key={index} {...toolProps} itemLevel={baseItemLevel + 1} />
+          ))}
+
+          { hasChildren() && currentChildren.map((node) => {
+            const itemProps = {
+              ...baseProps,
+              className,
+              itemLevel: baseItemLevel + 1,
+              itemNode: node,
+              itemClass,
+              itemClassName,
+              onClick,
+            };
+
+            return (
+              <ItemClassFixed {...itemProps} />
+            );
+          }) }
+
+        </div>
+      ) }
+    </div>
+  );
+};

+ 1 - 0
apps/app/src/components/TreeItem/_tree-item-variables.scss

@@ -0,0 +1 @@
+$btn-triangle-min-width: 35px;

+ 1 - 1
apps/app/src/components/TreeItem/index.ts

@@ -2,5 +2,5 @@ export * from './interfaces';
 
 export * from './NewPageInput';
 export * from './ItemNode';
-export * from './SimpleItem';
+export * from './TreeItemLayout';
 export * from './NotDraggableForClosableTextInput';

+ 10 - 6
apps/app/src/components/TreeItem/interfaces/index.ts

@@ -7,27 +7,31 @@ import type { IPageForPageDuplicateModal } from '~/stores/modal';
 import type { ItemNode } from '../ItemNode';
 
 type TreeItemBaseProps = {
+  itemLevel?: number,
   itemNode: ItemNode,
   isEnableActions: boolean,
   isReadOnlyUser: boolean,
   onClickDuplicateMenuItem?(pageToDuplicate: IPageForPageDuplicateModal): void,
   onClickDeleteMenuItem?(pageToDelete: IPageToDeleteWithMeta): void,
   onRenamed?(fromPath: string | undefined, toPath: string): void,
+}
+
+export type TreeItemToolProps = TreeItemBaseProps & {
   stateHandlers?: {
-    isOpen: boolean,
     setIsOpen: React.Dispatch<React.SetStateAction<boolean>>,
   },
-}
-
-export type TreeItemToolProps = TreeItemBaseProps;
+};
 
 export type TreeItemProps = TreeItemBaseProps & {
   targetPathOrId?: Nullable<string>,
   isOpen?: boolean,
   isWipPageShown?: boolean,
   itemClass?: React.FunctionComponent<TreeItemProps>,
-  mainClassName?: string,
+  itemClassName?: string,
   customEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
-  customNextComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
+  customHoveredEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
+  customHeadOfChildrenComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
+  showAlternativeContent?: boolean,
+  customAlternativeComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
   onClick?(page: IPageForItem): void,
 };

+ 8 - 0
apps/app/src/interfaces/websocket.ts

@@ -40,10 +40,18 @@ export const SocketEventName = {
   // External user group sync
   externalUserGroup: generateGroupSyncEvents(),
 
+  // room per pageId
+  JoinPage: 'join:page',
+  LeavePage: 'leave:page',
+
   // Page Operation
   PageCreated: 'page:create',
   PageUpdated: 'page:update',
   PageDeleted: 'page:delete',
+
+  // Yjs
+  YjsAwarenessStateSizeUpdated: 'yjs:awareness-state-size-update',
+  YjsHasRevisionBodyDiffUpdated: 'yjs:has-revision-body-diff-update',
 } as const;
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 

+ 4 - 0
apps/app/src/interfaces/yjs.ts

@@ -0,0 +1,4 @@
+export type CurrentPageYjsData = {
+  hasRevisionBodyDiff?: boolean,
+  awarenessStateSize?: number,
+}

+ 18 - 1
apps/app/src/pages/[[...path]].page.tsx

@@ -27,6 +27,7 @@ import { SupportedAction, type SupportedActionType } from '~/interfaces/activity
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
+import type { CurrentPageYjsData } from '~/interfaces/yjs';
 import type { PageModel, PageDocument } from '~/server/models/page';
 import type { PageRedirectModel } from '~/server/models/page-redirect';
 import {
@@ -49,6 +50,7 @@ import {
 import { useRedirectFrom } from '~/stores/page-redirect';
 import { useRemoteRevisionId } from '~/stores/remote-latest-page';
 import { useSetupGlobalSocket, useSetupGlobalSocketForPage } from '~/stores/websocket';
+import { useCurrentPageYjsData, useSWRMUTxCurrentPageYjsData } from '~/stores/yjs';
 import loggerFactory from '~/utils/logger';
 
 import { BasicLayout } from '../components/Layout/BasicLayout';
@@ -172,6 +174,8 @@ type Props = CommonProps & {
   skipSSR: boolean,
   ssrMaxRevisionBodyLength: number,
 
+  yjsData: CurrentPageYjsData,
+
   rendererConfig: RendererConfig,
 };
 
@@ -232,6 +236,8 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   const { data: currentPage } = useSWRxCurrentPage(pageWithMeta?.data ?? null); // store initial data
 
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
+  const { trigger: mutateCurrentPageYjsDataFromApi } = useSWRMUTxCurrentPageYjsData();
+
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
   const { data: currentPageId, mutate: mutateCurrentPageId } = useCurrentPageId();
 
@@ -244,6 +250,8 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   const { mutate: mutateTemplateTagData } = useTemplateTagData();
   const { mutate: mutateTemplateBodyData } = useTemplateBodyData();
 
+  const { mutate: mutateCurrentPageYjsData } = useCurrentPageYjsData();
+
   useSetupGlobalSocket();
   useSetupGlobalSocketForPage(pageId);
 
@@ -257,13 +265,14 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
       const mutatePageData = async() => {
         const pageData = await mutateCurrentPage();
         mutateEditingMarkdown(pageData?.revision?.body);
+        mutateCurrentPageYjsDataFromApi();
       };
 
       // If skipSSR is true, use the API to retrieve page data.
       // Because pageWIthMeta does not contain revision.body
       mutatePageData();
     }
-  }, [currentPageId, mutateCurrentPage, mutateEditingMarkdown, props.isNotFound, props.skipSSR]);
+  }, [currentPageId, mutateCurrentPage, mutateCurrentPageYjsDataFromApi, mutateEditingMarkdown, props.isNotFound, props.skipSSR]);
 
   // sync pathname by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
   useEffect(() => {
@@ -306,6 +315,10 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
     mutateTemplateBodyData(props.templateBodyData);
   }, [props.templateBodyData, mutateTemplateBodyData]);
 
+  useEffect(() => {
+    mutateCurrentPageYjsData(props.yjsData);
+  }, [mutateCurrentPageYjsData, props.yjsData]);
+
   // If the data on the page changes without router.push, pageWithMeta remains old because getServerSideProps() is not executed
   // So preferentially take page data from useSWRxCurrentPage
   const pagePath = currentPage?.path ?? pageWithMeta?.data.path ?? props.currentPathname;
@@ -485,6 +498,10 @@ async function injectRoutingInformation(context: GetServerSidePropsContext, prop
         props.currentPathname = `/${page._id}`;
       }
     }
+
+    if (!props.skipSSR) {
+      props.yjsData = await crowi.pageService.getYjsData(page._id);
+    }
   }
 }
 

+ 10 - 5
apps/app/src/server/models/activity.ts

@@ -1,13 +1,17 @@
 import type { Ref, IPage } from '@growi/core';
-import {
-  Types, Document, Model, Schema, SortOrder,
+import type {
+  Types, Document, Model, SortOrder,
 } from 'mongoose';
+import { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
+import type {
+  IActivity, ISnapshot, SupportedActionType, SupportedTargetModelType, SupportedEventModelType,
+} from '~/interfaces/activity';
 import {
-  IActivity, ISnapshot, AllSupportedActions, SupportedActionType,
-  AllSupportedTargetModels, SupportedTargetModelType,
-  AllSupportedEventModels, SupportedEventModelType,
+  AllSupportedActions,
+  AllSupportedTargetModels,
+  AllSupportedEventModels,
 } from '~/interfaces/activity';
 
 import loggerFactory from '../../utils/logger';
@@ -83,6 +87,7 @@ const activitySchema = new Schema<ActivityDocument, ActivityModel>({
     updatedAt: false,
   },
 });
+// activitySchema.index({ createdAt: 1 }); // Do not create index here because it is created by ActivityService as TTL index
 activitySchema.index({ target: 1, action: 1 });
 activitySchema.index({
   user: 1, target: 1, action: 1, createdAt: 1,

+ 6 - 4
apps/app/src/server/models/in-app-notification.ts

@@ -1,6 +1,5 @@
-import {
-  Types, Document, Schema, Model,
-} from 'mongoose';
+import type { Types, Document, Model } from 'mongoose';
+import { Schema } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
 import { AllSupportedTargetModels, AllSupportedActions } from '~/interfaces/activity';
@@ -8,7 +7,7 @@ import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 
 import { getOrCreateModel } from '../util/mongoose-utils';
 
-import { ActivityDocument } from './activity';
+import type { ActivityDocument } from './activity';
 
 
 const { STATUS_UNREAD, STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
@@ -79,6 +78,9 @@ const inAppNotificationSchema = new Schema<InAppNotificationDocument, InAppNotif
 }, {
   timestamps: { createdAt: true, updatedAt: false },
 });
+// indexes
+inAppNotificationSchema.index({ createdAt: 1 });
+// apply plugins
 inAppNotificationSchema.plugin(mongoosePaginate);
 
 const transform = (doc, ret) => {

+ 3 - 0
apps/app/src/server/models/page.ts

@@ -153,6 +153,9 @@ const schema = new Schema<PageDocument, PageModel>({
   toJSON: { getters: true },
   toObject: { getters: true },
 });
+// indexes
+schema.index({ createdAt: 1 });
+schema.index({ updatedAt: 1 });
 // apply plugins
 schema.plugin(mongoosePaginate);
 schema.plugin(uniqueValidator);

+ 2 - 2
apps/app/src/server/models/user.js

@@ -49,7 +49,7 @@ module.exports = function(crowi) {
     isGravatarEnabled: { type: Boolean, default: false },
     isEmailPublished: { type: Boolean, default: true },
     googleId: String,
-    name: { type: String },
+    name: { type: String, index: true },
     username: { type: String, required: true, unique: true },
     email: { type: String, unique: true, sparse: true },
     slackMemberId: { type: String, unique: true, sparse: true },
@@ -69,7 +69,7 @@ module.exports = function(crowi) {
     status: {
       type: Number, required: true, default: STATUS_ACTIVE, index: true,
     },
-    lastLoginAt: { type: Date },
+    lastLoginAt: { type: Date, index: true },
     admin: { type: Boolean, default: 0, index: true },
     readOnly: { type: Boolean, default: 0 },
     isInvitationEmailSended: { type: Boolean, default: false },

+ 12 - 6
apps/app/src/server/routes/apiv3/installer.ts

@@ -1,16 +1,16 @@
 import { ErrorV3 } from '@growi/core/dist/models';
-import express, { Request, Router } from 'express';
+import type { Request, Router } from 'express';
+import express from 'express';
 
 import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 
-import Crowi from '../../crowi';
+import type Crowi from '../../crowi';
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
-import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
-import { registerRules } from '../../middlewares/register-form-validator';
+import { registerRules, registerValidation } from '../../middlewares/register-form-validator';
 import { InstallerService, FailedToCreateAdminUserError } from '../../service/installer';
 
-import { ApiV3Response } from './interfaces/apiv3-response';
+import type { ApiV3Response } from './interfaces/apiv3-response';
 
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -27,11 +27,17 @@ module.exports = (crowi: Crowi): Router => {
   const router = express.Router();
 
   // eslint-disable-next-line max-len
-  router.post('/', registerRules(), apiV3FormValidator, addActivity, async(req: FormRequest, res: ApiV3Response) => {
+  router.post('/', registerRules(), registerValidation, addActivity, async(req: FormRequest, res: ApiV3Response) => {
     const appService = crowi.appService;
     if (appService == null) {
       return res.apiv3Err(new ErrorV3('GROWI cannot be installed due to an internal error', 'app_service_not_setup'), 500);
     }
+
+    if (!req.form.isValid) {
+      const errors = req.form.errors;
+      return res.apiv3Err(errors, 400);
+    }
+
     const registerForm = req.body.registerForm || {};
 
     const name = registerForm.name;

+ 4 - 2
apps/app/src/server/routes/apiv3/page/create-page.ts

@@ -22,6 +22,7 @@ import {
 import type { PageDocument, PageModel } from '~/server/models/page';
 import PageTagRelation from '~/server/models/page-tag-relation';
 import { configManager } from '~/server/service/config-manager';
+import { getTranslation } from '~/server/service/i18next';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
@@ -43,8 +44,9 @@ async function generateUntitledPath(parentPath: string, basePathname: string, in
 }
 
 async function determinePath(_parentPath?: string, _path?: string, optionalParentPath?: string): Promise<string> {
-  // TODO: https://redmine.weseek.co.jp/issues/142729
-  const basePathname = 'Untitled';
+  const { t } = await getTranslation();
+
+  const basePathname = t?.('create_page.untitled') || 'Untitled';
 
   if (_path != null) {
     const path = normalizePath(_path);

+ 57 - 0
apps/app/src/server/routes/apiv3/page/get-yjs-data.ts

@@ -0,0 +1,57 @@
+import type { IPage, IUserHasId } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import type { ValidationChain } from 'express-validator';
+import { param } from 'express-validator';
+import mongoose from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+import type { PageModel } from '~/server/models/page';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:page:get-yjs-data');
+
+type GetYjsDataHandlerFactory = (crowi: Crowi) => RequestHandler[];
+
+type ReqParams = {
+  pageId: string,
+}
+interface Req extends Request<ReqParams, ApiV3Response> {
+  user: IUserHasId,
+}
+export const getYjsDataHandlerFactory: GetYjsDataHandlerFactory = (crowi) => {
+  const Page = mongoose.model<IPage, PageModel>('Page');
+  const accessTokenParser = require('../../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+
+  // define validators for req.params
+  const validator: ValidationChain[] = [
+    param('pageId').isMongoId().withMessage('The param "pageId" must be specified'),
+  ];
+
+  return [
+    accessTokenParser, loginRequiredStrictly,
+    validator, apiV3FormValidator,
+    async(req: Req, res: ApiV3Response) => {
+      const { pageId } = req.params;
+
+      // check whether accessible
+      if (!(await Page.isAccessiblePageByViewer(pageId, req.user))) {
+        return res.apiv3Err(new ErrorV3('Current user is not accessible to this page.', 'forbidden-page'), 403);
+      }
+
+      try {
+        const yjsData = await crowi.pageService.getYjsData(pageId);
+        return res.apiv3({ yjsData });
+      }
+      catch (err) {
+        logger.error(err);
+        return res.apiv3Err(err);
+      }
+    },
+  ];
+};

+ 3 - 0
apps/app/src/server/routes/apiv3/page/index.ts

@@ -24,6 +24,7 @@ import loggerFactory from '~/utils/logger';
 
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { createPageHandlersFactory } from './create-page';
+import { getYjsDataHandlerFactory } from './get-yjs-data';
 import { publishPageHandlersFactory } from './publish-page';
 import { unpublishPageHandlersFactory } from './unpublish-page';
 import { updatePageHandlersFactory } from './update-page';
@@ -908,5 +909,7 @@ module.exports = (crowi) => {
 
   router.put('/:pageId/unpublish', unpublishPageHandlersFactory(crowi));
 
+  router.get('/:pageId/yjs-data', getYjsDataHandlerFactory(crowi));
+
   return router;
 };

+ 47 - 0
apps/app/src/server/service/i18next.ts

@@ -0,0 +1,47 @@
+import path from 'path';
+
+import type { Lang } from '@growi/core';
+import type { TFunction, i18n } from 'i18next';
+import { createInstance } from 'i18next';
+import resourcesToBackend from 'i18next-resources-to-backend';
+
+import { defaultLang, initOptions } from '^/config/i18next.config';
+
+import { resolveFromRoot } from '~/utils/project-dir-utils';
+
+import { configManager } from './config-manager';
+
+
+const relativePathToLocalesRoot = path.relative(__dirname, resolveFromRoot('public/static/locales'));
+
+const initI18next = async(lang: Lang = defaultLang) => {
+  const i18nInstance = createInstance();
+  await i18nInstance
+    .use(
+      resourcesToBackend(
+        (language: string, namespace: string) => {
+          return import(path.join(relativePathToLocalesRoot, language, `${namespace}.json`));
+        },
+      ),
+    )
+    .init({
+      ...initOptions,
+      lng: lang,
+    });
+  return i18nInstance;
+};
+
+type Translation = {
+  t: TFunction,
+  i18n: i18n
+}
+
+export async function getTranslation(lang?: Lang): Promise<Translation> {
+  const globalLang = configManager.getConfig('crowi', 'app:globalLang') as Lang;
+  const i18nextInstance = await initI18next(globalLang);
+
+  return {
+    t: i18nextInstance.getFixedT(lang ?? globalLang),
+    i18n: i18nextInstance,
+  };
+}

+ 29 - 0
apps/app/src/server/service/page/index.ts

@@ -33,6 +33,7 @@ import {
 } from '~/interfaces/page-operation';
 import { PageActionOnGroupDelete } from '~/interfaces/user-group';
 import { SocketEventName, type PageMigrationErrorData, type UpdateDescCountRawData } from '~/interfaces/websocket';
+import type { CurrentPageYjsData } from '~/interfaces/yjs';
 import type { CreateMethod } from '~/server/models/page';
 import {
   type PageModel, type PageDocument, pushRevision, PageQueryBuilder,
@@ -40,6 +41,7 @@ import {
 import type { PageTagRelationDocument } from '~/server/models/page-tag-relation';
 import PageTagRelation from '~/server/models/page-tag-relation';
 import type { UserGroupDocument } from '~/server/models/user-group';
+import { getYjsConnectionManager } from '~/server/service/yjs-connection-manager';
 import { createBatchStream } from '~/server/util/batch-stream';
 import { collectAncestorPaths } from '~/server/util/collect-ancestor-paths';
 import loggerFactory from '~/utils/logger';
@@ -4447,6 +4449,33 @@ class PageService implements IPageService {
     });
   }
 
+  async getYjsData(pageId: string): Promise<CurrentPageYjsData> {
+    const yjsConnectionManager = getYjsConnectionManager();
+    const currentYdoc = yjsConnectionManager.getCurrentYdoc(pageId);
+    const yjsDraft = currentYdoc?.getText('codemirror').toString();
+    const hasRevisionBodyDiff = await this.hasRevisionBodyDiff(pageId, yjsDraft);
+
+    return {
+      hasRevisionBodyDiff,
+      awarenessStateSize: currentYdoc?.awareness.states.size,
+    };
+  }
+
+  async hasRevisionBodyDiff(pageId: string, comparisonTarget?: string): Promise<boolean> {
+    if (comparisonTarget == null) {
+      return false;
+    }
+
+    const Revision = mongoose.model<IRevisionHasId>('Revision');
+    const revision = await Revision.findOne({ pageId }).sort({ createdAt: -1 });
+
+    if (revision == null) {
+      return false;
+    }
+
+    return revision.body !== comparisonTarget;
+  }
+
   async createTtlIndex(): Promise<void> {
     const wipPageExpirationSeconds = configManager.getConfig('crowi', 'app:wipPageExpirationSeconds') ?? 172800;
     const collection = mongoose.connection.collection('pages');

+ 2 - 0
apps/app/src/server/service/page/page-service.ts

@@ -8,6 +8,7 @@ import type { ObjectId } from 'mongoose';
 
 import type { IOptionsForCreate, IOptionsForUpdate } from '~/interfaces/page';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
+import type { CurrentPageYjsData } from '~/interfaces/yjs';
 import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import type { PageDocument } from '~/server/models/page';
 
@@ -30,4 +31,5 @@ export interface IPageService {
   canDeleteCompletelyAsMultiGroupGrantedPage(
     page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, userRelatedGroups: PopulatedGrantedGroup[]
   ): boolean,
+  getYjsData(pageId: string, revisionBody?: string): Promise<CurrentPageYjsData>,
 }

+ 35 - 2
apps/app/src/server/service/socket-io.js

@@ -1,11 +1,13 @@
 import { GlobalSocketEventName } from '@growi/core/dist/interfaces';
 import { Server } from 'socket.io';
 
+import { SocketEventName } from '~/interfaces/websocket';
 import loggerFactory from '~/utils/logger';
 
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 
-import { getYjsConnectionManager } from './yjs-connection-manager';
+import { getYjsConnectionManager, extractPageIdFromYdocId } from './yjs-connection-manager';
+
 
 const expressSession = require('express-session');
 const passport = require('passport');
@@ -51,6 +53,7 @@ class SocketIoService {
 
     await this.setupLoginedUserRoomsJoinOnConnection();
     await this.setupDefaultSocketJoinRoomsEventHandler();
+    await this.setupDefaultSocketLeaveRoomsEventHandler();
   }
 
   getDefaultSocket() {
@@ -149,15 +152,45 @@ class SocketIoService {
   setupDefaultSocketJoinRoomsEventHandler() {
     this.io.on('connection', (socket) => {
       // set event handlers for joining rooms
-      socket.on('join:page', ({ pageId }) => {
+      socket.on(SocketEventName.JoinPage, ({ pageId }) => {
         socket.join(getRoomNameWithId(RoomPrefix.PAGE, pageId));
       });
     });
   }
 
+  setupDefaultSocketLeaveRoomsEventHandler() {
+    this.io.on('connection', (socket) => {
+      socket.on(SocketEventName.LeavePage, ({ pageId }) => {
+        socket.leave(getRoomNameWithId(RoomPrefix.PAGE, pageId));
+      });
+    });
+  }
+
   setupYjsConnection() {
     const yjsConnectionManager = getYjsConnectionManager();
+
     this.io.on('connection', (socket) => {
+
+      yjsConnectionManager.ysocketioInstance.on('awareness-update', async(update) => {
+        const pageId = extractPageIdFromYdocId(update.name);
+        const awarenessStateSize = update.awareness.states.size;
+
+        // Triggered when awareness changes
+        this.io
+          .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
+          .emit(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSize);
+
+        // Triggered when the last user leaves the editor
+        if (awarenessStateSize === 0) {
+          const currentYdoc = yjsConnectionManager.getCurrentYdoc(pageId);
+          const yjsDraft = currentYdoc?.getText('codemirror').toString();
+          const hasRevisionBodyDiff = await this.crowi.pageService.hasRevisionBodyDiff(pageId, yjsDraft);
+          this.io
+            .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
+            .emit(SocketEventName.YjsHasRevisionBodyDiffUpdated, hasRevisionBodyDiff);
+        }
+      });
+
       socket.on(GlobalSocketEventName.YDocSync, async({ pageId, initialValue }) => {
         try {
           await yjsConnectionManager.handleYDocSync(pageId, initialValue);

+ 20 - 7
apps/app/src/server/service/yjs-connection-manager.ts

@@ -1,6 +1,6 @@
 import type { Server } from 'socket.io';
 import { MongodbPersistence } from 'y-mongodb-provider';
-import { YSocketIO } from 'y-socket.io/dist/server';
+import { YSocketIO, type Document as Ydoc } from 'y-socket.io/dist/server';
 import * as Y from 'yjs';
 
 import { getMongoUri } from '../util/mongoose-utils';
@@ -8,6 +8,11 @@ import { getMongoUri } from '../util/mongoose-utils';
 const MONGODB_PERSISTENCE_COLLECTION_NAME = 'yjs-writings';
 const MONGODB_PERSISTENCE_FLUSH_SIZE = 100;
 
+export const extractPageIdFromYdocId = (ydocId: string): string | undefined => {
+  const result = ydocId.match(/yjs\/(.*)/);
+  return result?.[1];
+};
+
 class YjsConnectionManager {
 
   private static instance: YjsConnectionManager;
@@ -16,6 +21,10 @@ class YjsConnectionManager {
 
   private mdb: MongodbPersistence;
 
+  get ysocketioInstance(): YSocketIO {
+    return this.ysocketio;
+  }
+
   private constructor(io: Server) {
     this.ysocketio = new YSocketIO(io);
     this.ysocketio.initialize();
@@ -40,13 +49,16 @@ class YjsConnectionManager {
   }
 
   public async handleYDocSync(pageId: string, initialValue: string): Promise<void> {
+    const currentYdoc = this.getCurrentYdoc(pageId);
+    if (currentYdoc == null) {
+      return;
+    }
+
     const persistedYdoc = await this.mdb.getYDoc(pageId);
     const persistedStateVector = Y.encodeStateVector(persistedYdoc);
 
     await this.mdb.flushDocument(pageId);
 
-    const currentYdoc = this.getCurrentYdoc(pageId);
-
     const persistedCodeMirrorText = persistedYdoc.getText('codemirror').toString();
     const currentCodeMirrorText = currentYdoc.getText('codemirror').toString();
 
@@ -77,17 +89,18 @@ class YjsConnectionManager {
     // TODO: https://redmine.weseek.co.jp/issues/132775
     // It's necessary to confirm that the user is not editing the target page in the Editor
     const currentYdoc = this.getCurrentYdoc(pageId);
+    if (currentYdoc == null) {
+      return;
+    }
+
     const currentMarkdownLength = currentYdoc.getText('codemirror').length;
     currentYdoc.getText('codemirror').delete(0, currentMarkdownLength);
     currentYdoc.getText('codemirror').insert(0, newValue);
     Y.encodeStateAsUpdate(currentYdoc);
   }
 
-  private getCurrentYdoc(pageId: string): Y.Doc {
+  public getCurrentYdoc(pageId: string): Ydoc | undefined {
     const currentYdoc = this.ysocketio.documents.get(`yjs/${pageId}`);
-    if (currentYdoc == null) {
-      throw new Error(`currentYdoc for pageId ${pageId} is undefined.`);
-    }
     return currentYdoc;
   }
 

+ 1 - 0
apps/app/src/stores/page.tsx

@@ -23,6 +23,7 @@ import type { AxiosResponse } from '~/utils/axios';
 
 import type { IPageTagsInfo } from '../interfaces/tag';
 
+
 import {
   useCurrentPathname, useShareLinkId, useIsGuestUser, useIsReadOnlyUser,
 } from './context';

+ 0 - 27
apps/app/src/stores/slide-viewer-renderer.tsx

@@ -1,27 +0,0 @@
-import useSWR, { type SWRResponse } from 'swr';
-
-import type { RendererOptions } from '~/interfaces/renderer-options';
-import { useRendererConfig } from '~/stores/context';
-import { useCurrentPagePath } from '~/stores/page';
-
-
-export const usePresentationViewOptions = (): SWRResponse<RendererOptions, Error> => {
-  const { data: currentPagePath } = useCurrentPagePath();
-  const { data: rendererConfig } = useRendererConfig();
-
-  const isAllDataValid = currentPagePath != null && rendererConfig != null;
-
-  return useSWR(
-    isAllDataValid
-      ? ['presentationViewOptions', currentPagePath, rendererConfig]
-      : null,
-    async([, currentPagePath, rendererConfig]) => {
-      const { generatePresentationViewOptions } = await import('~/client/services/renderer/slide-viewer-renderer');
-      return generatePresentationViewOptions(rendererConfig, currentPagePath);
-    },
-    {
-      revalidateOnFocus: false,
-      revalidateOnReconnect: false,
-    },
-  );
-};

+ 4 - 4
apps/app/src/stores/ui.tsx

@@ -52,10 +52,6 @@ export type EditorMode = typeof EditorMode[keyof typeof EditorMode];
  *                     Storing objects to ref
  *********************************************************** */
 
-export const useSidebarScrollerRef = (initialData?: RefObject<SimpleBar>): SWRResponse<RefObject<SimpleBar>, Error> => {
-  return useStaticSWR<RefObject<SimpleBar>, Error>('sidebarScrollerRef', initialData);
-};
-
 export const useCurrentPageTocNode = (): SWRResponse<HtmlElementNode, any> => {
   const { data: currentPagePath } = useCurrentPagePath();
 
@@ -67,6 +63,10 @@ export const useCurrentPageTocNode = (): SWRResponse<HtmlElementNode, any> => {
  *                      for switching UI
  *********************************************************** */
 
+export const useSidebarScrollerRef = (initialData?: RefObject<HTMLDivElement>): SWRResponse<RefObject<HTMLDivElement>, Error> => {
+  return useSWRStatic<RefObject<HTMLDivElement>, Error>('sidebarScrollerRef', initialData);
+};
+
 export const useIsMobile = (): SWRResponse<boolean, Error> => {
   const key = isClient() ? 'isMobile' : null;
 

+ 7 - 2
apps/app/src/stores/websocket.tsx

@@ -2,8 +2,9 @@ import { useEffect } from 'react';
 
 import { useGlobalSocket, GLOBAL_SOCKET_KEY, GLOBAL_SOCKET_NS } from '@growi/core/dist/swr';
 import type { Socket } from 'socket.io-client';
-import { SWRResponse } from 'swr';
+import type { SWRResponse } from 'swr';
 
+import { SocketEventName } from '~/interfaces/websocket';
 import loggerFactory from '~/utils/logger';
 
 import { useStaticSWR } from './use-static-swr';
@@ -67,6 +68,10 @@ export const useSetupGlobalSocketForPage = (pageId: string | undefined): void =>
   useEffect(() => {
     if (socket == null || pageId == null) { return }
 
-    socket.emit('join:page', { socketId: socket.id, pageId });
+    socket.emit(SocketEventName.JoinPage, { pageId });
+
+    return () => {
+      socket.emit(SocketEventName.LeavePage, { pageId });
+    };
   }, [pageId, socket]);
 };

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