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

Merge branch 'master' into imprv/140987-145787-display-selected-TreeItem-in-PageSelectModal-as-active

kosei-n 1 год назад
Родитель
Сommit
54876ac972
85 измененных файлов с 1776 добавлено и 1317 удалено
  1. 2 0
      apps/app/package.json
  2. 1 0
      apps/app/public/static/locales/en_US/commons.json
  3. 14 10
      apps/app/public/static/locales/en_US/translation.json
  4. 1 0
      apps/app/public/static/locales/fr_FR/commons.json
  5. 14 10
      apps/app/public/static/locales/fr_FR/translation.json
  6. 1 0
      apps/app/public/static/locales/ja_JP/commons.json
  7. 14 10
      apps/app/public/static/locales/ja_JP/translation.json
  8. 1 0
      apps/app/public/static/locales/zh_CN/commons.json
  9. 14 10
      apps/app/public/static/locales/zh_CN/translation.json
  10. 33 0
      apps/app/src/client/services/side-effects/yjs.ts
  11. 0 32
      apps/app/src/client/util/input-validator.ts
  12. 56 0
      apps/app/src/client/util/use-input-validator.ts
  13. 12 13
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  14. 54 8
      apps/app/src/components/Bookmarks/BookmarkFolderNameInput.tsx
  15. 4 8
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  16. 68 0
      apps/app/src/components/Bookmarks/BookmarkItemRenameInput.tsx
  17. 0 1
      apps/app/src/components/Bookmarks/BookmarkMoveToRootBtn.tsx
  18. 0 146
      apps/app/src/components/Common/ClosableTextInput.tsx
  19. 1 1
      apps/app/src/components/Common/Dropdown/PageItemControl.spec.tsx
  20. 2 2
      apps/app/src/components/Common/Dropdown/PageItemControl.tsx
  21. 30 0
      apps/app/src/components/Common/SubmittableInput/AutosizeSubmittableInput.tsx
  22. 23 0
      apps/app/src/components/Common/SubmittableInput/SubmittableInput.tsx
  23. 2 0
      apps/app/src/components/Common/SubmittableInput/index.ts
  24. 7 0
      apps/app/src/components/Common/SubmittableInput/types.d.ts
  25. 80 0
      apps/app/src/components/Common/SubmittableInput/use-submittable.ts
  26. 0 153
      apps/app/src/components/ItemsTree/ItemsTree.module.scss
  27. 3 3
      apps/app/src/components/ItemsTree/ItemsTree.tsx
  28. 11 0
      apps/app/src/components/ItemsTree/ItemsTreeContentSkeleton.module.scss
  29. 5 7
      apps/app/src/components/ItemsTree/ItemsTreeContentSkeleton.tsx
  30. 14 2
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  31. 2 0
      apps/app/src/components/Page/DisplaySwitcher.tsx
  32. 35 39
      apps/app/src/components/PageHeader/PagePathHeader.tsx
  33. 31 15
      apps/app/src/components/PageHeader/PageTitleHeader.tsx
  34. 5 0
      apps/app/src/components/PageSelectModal/TreeItemForModal.module.scss
  35. 11 4
      apps/app/src/components/PageSelectModal/TreeItemForModal.tsx
  36. 2 3
      apps/app/src/components/Sidebar/Bookmarks/BookmarkContents.tsx
  37. 9 0
      apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx
  38. 6 0
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  39. 7 7
      apps/app/src/components/Sidebar/PageTree/PageTreeSubstance.tsx
  40. 19 0
      apps/app/src/components/Sidebar/PageTreeItem/CountBadgeForPageTreeItem.tsx
  41. 13 0
      apps/app/src/components/Sidebar/PageTreeItem/CreatingNewPageSpinner.tsx
  42. 0 179
      apps/app/src/components/Sidebar/PageTreeItem/Ellipsis.tsx
  43. 21 2
      apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.module.scss
  44. 28 27
      apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  45. 0 1
      apps/app/src/components/Sidebar/PageTreeItem/index.ts
  46. 235 0
      apps/app/src/components/Sidebar/PageTreeItem/use-page-item-control.tsx
  47. 17 13
      apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  48. 3 2
      apps/app/src/components/Sidebar/SidebarNav/SecondaryItems.tsx
  49. 4 0
      apps/app/src/components/Skeleton.module.scss
  50. 5 2
      apps/app/src/components/Skeleton.tsx
  51. 2 2
      apps/app/src/components/TreeItem/NewPageInput/NewPageCreateButton.tsx
  52. 6 0
      apps/app/src/components/TreeItem/NewPageInput/NewPageInput.module.scss
  53. 0 79
      apps/app/src/components/TreeItem/NewPageInput/NewPageInput.tsx
  54. 102 42
      apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx
  55. 0 265
      apps/app/src/components/TreeItem/SimpleItem.tsx
  56. 7 0
      apps/app/src/components/TreeItem/SimpleItemContent.module.scss
  57. 46 0
      apps/app/src/components/TreeItem/SimpleItemContent.tsx
  58. 36 0
      apps/app/src/components/TreeItem/TreeItemLayout.module.scss
  59. 235 0
      apps/app/src/components/TreeItem/TreeItemLayout.tsx
  60. 1 0
      apps/app/src/components/TreeItem/_tree-item-variables.scss
  61. 1 1
      apps/app/src/components/TreeItem/index.ts
  62. 10 6
      apps/app/src/components/TreeItem/interfaces/index.ts
  63. 8 0
      apps/app/src/interfaces/websocket.ts
  64. 4 0
      apps/app/src/interfaces/yjs.ts
  65. 18 1
      apps/app/src/pages/[[...path]].page.tsx
  66. 10 5
      apps/app/src/server/models/activity.ts
  67. 6 4
      apps/app/src/server/models/in-app-notification.ts
  68. 3 0
      apps/app/src/server/models/page.ts
  69. 2 2
      apps/app/src/server/models/user.js
  70. 57 0
      apps/app/src/server/routes/apiv3/page/get-yjs-data.ts
  71. 3 0
      apps/app/src/server/routes/apiv3/page/index.ts
  72. 29 0
      apps/app/src/server/service/page/index.ts
  73. 2 0
      apps/app/src/server/service/page/page-service.ts
  74. 35 2
      apps/app/src/server/service/socket-io.js
  75. 20 7
      apps/app/src/server/service/yjs-connection-manager.ts
  76. 1 0
      apps/app/src/stores/page.tsx
  77. 7 2
      apps/app/src/stores/websocket.tsx
  78. 42 0
      apps/app/src/stores/yjs.ts
  79. 1 1
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-page.cy.ts
  80. 1 1
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--use-tools.cy.ts
  81. 2 2
      apps/app/test/cypress/e2e/30-search/30-search--search.cy.ts
  82. 141 157
      apps/app/test/cypress/e2e/50-sidebar/50-sidebar--access-to-side-bar.cy.ts
  83. 2 0
      apps/app/test/cypress/support/index.ts
  84. 1 0
      apps/app/test/tsconfig.json
  85. 15 28
      yarn.lock

+ 2 - 0
apps/app/package.json

@@ -231,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",
@@ -242,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",

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

+ 14 - 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."
   },

+ 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": "メモ"

+ 14 - 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": "このパスではページ コンテンツを作成できません。"
   },

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

+ 14 - 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": "无法在此路径中创建页面内容。"
   },

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

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

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

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

+ 3 - 3
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';
@@ -31,7 +31,7 @@ import ItemsTreeContentSkeleton from './ItemsTreeContentSkeleton';
 
 import styles from './ItemsTree.module.scss';
 
-const moduleClass = styles['grw-pagetree'] ?? '';
+const moduleClass = styles['items-tree'] ?? '';
 
 const logger = loggerFactory('growi:cli:ItemsTree');
 
@@ -223,7 +223,7 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
 
   if (initialItemNode != null) {
     return (
-      <ul className={`grw-pagetree ${moduleClass} list-group py-4`}>
+      <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 (
     <>

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

+ 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

@@ -4,10 +4,15 @@ import type { Nullable } from '@growi/core';
 
 import type { ItemNode } from '../TreeItem';
 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,
 };
@@ -19,9 +24,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}
@@ -29,9 +36,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}
       // markTarget={markTarget}
     />

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

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

@@ -172,7 +172,7 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
   }
 
   return (
-    <div ref={rootElemRef}>
+    <div ref={rootElemRef} className="pt-4">
       <ItemsTree
         isEnableActions={!isGuestUser}
         isReadOnlyUser={!!isReadOnlyUser}
@@ -183,13 +183,13 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
         CustomTreeItem={PageTreeItem}
       />
 
-      {!isGuestUser && !isReadOnlyUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
-        <div className="grw-pagetree-footer border-top py-3 w-100">
-          <div className="private-legacy-pages-link px-3 py-2">
-            <PrivateLegacyPagesLink />
-          </div>
+      {/* {!isGuestUser && !isReadOnlyUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && ( */}
+      <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 - 27
apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -19,14 +19,19 @@ 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) => {
@@ -57,10 +62,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;
@@ -71,17 +80,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 },
@@ -94,9 +92,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(),
@@ -130,8 +125,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'));
       }
@@ -166,15 +159,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}
@@ -186,10 +185,12 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
       onRenamed={props.onRenamed}
       itemRef={itemRef}
       itemClass={PageTreeItem}
-      mainClassName={mainClassName}
-      customEndComponents={[Ellipsis, NewPageCreateButton]}
-      customNextComponents={[NewPageInput]}
-      // markTarget={markTarget}
+      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>
     </>
   );
 };

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

+ 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 - 265
apps/app/src/components/TreeItem/SimpleItem.tsx

@@ -1,265 +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 = (page: IPageForItem, children: ItemNode[], targetPathOrId?: Nullable<string>): void => {
-
-  if (targetPathOrId == null) {
-    return;
-  }
-
-  page.isTarget = page.path === targetPathOrId;
-
-  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>,
-  // markTarget: (children: ItemNode[], targetPathOrId?: Nullable<string>) => void;
-}
-
-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(page, children, targetPathOrId);
-      setCurrentChildren(children);
-    }
-  }, [children, currentChildren.length, page, targetPathOrId]);
-
-  /*
-   * When swr fetch succeeded
-   */
-  useEffect(() => {
-    if (isOpen && data != null) {
-      const newChildren = ItemNode.generateNodesFromPages(data.children);
-      markTarget(page, newChildren, targetPathOrId);
-      setCurrentChildren(newChildren);
-    }
-  }, [data, isOpen, page, 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 });
 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 },

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

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

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

+ 42 - 0
apps/app/src/stores/yjs.ts

@@ -0,0 +1,42 @@
+import { useCallback } from 'react';
+
+import { useSWRStatic } from '@growi/core/dist/swr';
+import type { SWRResponse } from 'swr';
+import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import type { CurrentPageYjsData } from '~/interfaces/yjs';
+
+import { useCurrentPageId } from './page';
+
+type CurrentPageYjsDataUtils = {
+  updateHasRevisionBodyDiff(hasRevisionBodyDiff: boolean): void
+  updateAwarenessStateSize(awarenessStateSize: number): void
+}
+
+export const useCurrentPageYjsData = (): SWRResponse<CurrentPageYjsData, Error> & CurrentPageYjsDataUtils => {
+  const swrResponse = useSWRStatic<CurrentPageYjsData, Error>('currentPageYjsData', undefined);
+
+  const updateHasRevisionBodyDiff = useCallback((hasRevisionBodyDiff: boolean) => {
+    swrResponse.mutate({ ...swrResponse.data, hasRevisionBodyDiff });
+  }, [swrResponse]);
+
+  const updateAwarenessStateSize = useCallback((awarenessStateSize: number) => {
+    swrResponse.mutate({ ...swrResponse.data, awarenessStateSize });
+  }, [swrResponse]);
+
+  return {
+    ...swrResponse, updateHasRevisionBodyDiff, updateAwarenessStateSize,
+  };
+};
+
+export const useSWRMUTxCurrentPageYjsData = (): SWRMutationResponse<CurrentPageYjsData, Error> => {
+  const key = 'currentPageYjsData';
+  const { data: currentPageId } = useCurrentPageId();
+
+  return useSWRMutation(
+    key,
+    () => apiv3Get<{ yjsData: CurrentPageYjsData }>(`/page/${currentPageId}/yjs-data`).then(result => result.data.yjsData),
+    { populateCache: true, revalidate: false },
+  );
+};

+ 1 - 1
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-page.cy.ts

@@ -227,7 +227,7 @@ context('Access to Template Editing Mode', () => {
       });
     });
     cy.get('@pagetreeItem').within(() => {
-      cy.getByTestid('closable-text-input').type(newPagePath).type('{enter}');
+      cy.getByTestid('autosize-submittable-input').type(newPagePath).type('{enter}');
     })
 
     cy.visit(`/${parentPagePath}/${newPagePath}`);

+ 1 - 1
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--use-tools.cy.ts

@@ -73,7 +73,7 @@ context('Modal for page operation', () => {
       return cy.getByTestid('page-item-control-menu').then($elem => $elem.is(':visible'))
     });
 
-    cy.getByTestid('open-page-move-rename-modal-btn').filter(':visible').click({force: true});
+    cy.getByTestid('rename-page-btn').filter(':visible').click({force: true});
     cy.getByTestid('grw-page-rename-button').should('be.disabled');
 
     cy.getByTestid('page-rename-modal').should('be.visible').screenshot(`${ssPrefix}-rename-bootstrap5`);

+ 2 - 2
apps/app/test/cypress/e2e/30-search/30-search--search.cy.ts

@@ -328,7 +328,7 @@ context('Search and use', () => {
   it('Successfully add bookmark', () => {
     cy.get('.dropdown-menu.show').should('be.visible').within(() => {
       // Add bookmark
-      cy.getByTestid('add-remove-bookmark-btn').click({force: true});
+      cy.getByTestid('add-bookmark-btn').click({force: true});
     });
     cy.getByTestid('search-result-content').within(() => {
       cy.get('.btn-bookmark.active').should('be.visible');
@@ -349,7 +349,7 @@ context('Search and use', () => {
 
   it('Successfully open move/rename modal', () => {
     cy.get('.dropdown-menu.show').should('be.visible').within(() => {
-      cy.getByTestid('open-page-move-rename-modal-btn').click({force: true});
+      cy.getByTestid('rename-page-btn').click({force: true});
     });
     cy.getByTestid('page-rename-modal').should('be.visible').within(() => {
       cy.screenshot(`${ssPrefix}4-move-rename-page`);

+ 141 - 157
apps/app/test/cypress/e2e/50-sidebar/50-sidebar--access-to-side-bar.cy.ts

@@ -51,13 +51,7 @@ describe('Access to sidebar', () => {
 
       describe('Test page tree tab', () => {
         beforeEach(() => {
-          cy.getByTestid('grw-sidebar-nav-primary-page-tree').should('be.visible')
-            .then($elem => {
-              // open if inactive
-              if (!$elem.hasClass('active')) {
-                cy.getByTestid('grw-sidebar-nav-primary-page-tree').click();
-              }
-            });
+          cy.getByTestid('grw-sidebar-nav-primary-page-tree').click();
         });
 
         it('Successfully access to page tree', () => {
@@ -69,130 +63,129 @@ describe('Access to sidebar', () => {
           });
         });
 
-        it('Successfully hide page tree items', () => {
-          cy.getByTestid('grw-sidebar-contents').within(() => {
-            cy.get('.grw-pagetree-open').should('be.visible');
-
-            // hide page tree tiems
-            cy.get('.grw-pagetree-triangle-btn').first().click();
-
-            cy.screenshot(`${ssPrefix}page-tree-2-hide-page-tree-items`, { blackout: blackoutOverride });
-          });
-        });
-
-        it('Successfully click Add to Bookmarks button', () => {
-          cy.waitUntil(() => {
-            // do
-            cy.getByTestid('grw-sidebar-contents').within(() => {
-              cy.get('.grw-pagetree-item-children').first().as('pagetreeItem').within(() => {
-                cy.getByTestid('open-page-item-control-btn').find('button').first().invoke('css','display','block').click()
-              });
-            });
-            // wait until
-            return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
-          });
-
-          cy.screenshot(`${ssPrefix}page-tree-3-before-click-button`, { blackout: blackoutOverride });
-
-          // click add remove bookmark btn
-          cy.getByTestid('page-item-control-menu').should('have.class', 'show')
-          cy.getByTestid('add-remove-bookmark-btn').click();
-
-          // show dropdown again
-          cy.waitUntil(() => {
-            // do
-            cy.getByTestid('grw-sidebar-contents').within(() => {
-              cy.get('.grw-pagetree-item-children').first().as('pagetreeItem').within(() => {
-                cy.getByTestid('open-page-item-control-btn').find('button').first().invoke('css','display','block').click()
-              });
-            });
-            // wait until
-            return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
-          });
-
-          cy.screenshot(`${ssPrefix}page-tree-4-after-click-button`, { blackout: blackoutOverride });
-        });
-
-        it('Successfully show duplicate page modal', () => {
-          cy.waitUntil(() => {
-            // do
-            cy.getByTestid('grw-sidebar-contents').within(() => {
-              cy.get('.grw-pagetree-item-children').first().as('pagetreeItem').within(() => {
-                cy.getByTestid('open-page-item-control-btn').find('button').first().invoke('css','display','block').click()
-              });
-            });
-            // wait until
-            return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
-          });
-
-          cy.get('.dropdown-menu.show').within(() => {
-            cy.getByTestid('open-page-duplicate-modal-btn').should('be.visible').click();
-          });
-
-          cy.getByTestid('page-duplicate-modal').should('be.visible').within(() => {
-            cy.get('.form-control').type('_test');
-
-            cy.screenshot(`${ssPrefix}page-tree-5-duplicate-page-modal`, { blackout: blackoutOverride });
-
-            cy.get('.modal-header > button').click();
-          });
-        });
-
-        it('Successfully rename page', () => {
-          cy.waitUntil(() => {
-            // do
-            cy.getByTestid('grw-sidebar-contents').within(() => {
-              cy.get('.grw-pagetree-item-children').first().as('pagetreeItem').within(() => {
-                cy.getByTestid('open-page-item-control-btn').find('button').first().invoke('css','display','block').click()
-              });
-            });
-            // wait until
-            return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
-          });
-
-          cy.get('.dropdown-menu.show').within(() => {
-            cy.getByTestid('open-page-move-rename-modal-btn').should('be.visible').click();
-          });
-
-          cy.get('@pagetreeItem').within(() => {
-            cy.getByTestid('closable-text-input').type('_newname');
-          })
 
-          cy.screenshot(`${ssPrefix}page-tree-6-rename-page`, { blackout: blackoutOverride });
-        });
+        //
+        // Deactivate: An error occurs that cannot be reproduced in the development environment. -- Yuki Takei 2024.05.10
+        //
+
+        // it('Successfully click Add to Bookmarks button', () => {
+        //   cy.waitUntil(() => {
+        //     // do
+        //     cy.getByTestid('grw-sidebar-contents').within(() => {
+        //       cy.getByTestid('grw-pagetree-item-container').eq(1).within(() => { // against the second element
+        //         cy.get('li').realHover();
+        //         cy.getByTestid('open-page-item-control-btn').find('button').first().realClick();
+        //       });
+        //     });
+        //     // wait until
+        //     return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
+        //   });
+
+        //   cy.get('.dropdown-menu.show').should('be.visible').within(() => {
+        //     // take a screenshot for dropdown menu
+        //     cy.screenshot(`${ssPrefix}page-tree-2-before-adding-bookmark`)
+        //     // click add remove bookmark btn
+        //     cy.getByTestid('add-bookmark-btn').click();
+        //   })
+
+        //   // show dropdown again
+        //   cy.waitUntil(() => {
+        //     // do
+        //     cy.getByTestid('grw-sidebar-contents').within(() => {
+        //       cy.getByTestid('grw-pagetree-item-container').eq(1).within(() => { // against the second element
+        //         cy.get('li').realHover();
+        //         cy.getByTestid('open-page-item-control-btn').find('button').first().realClick();
+        //       });
+        //     });
+        //     // wait until
+        //     return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
+        //   });
+
+        //   cy.get('.dropdown-menu.show').should('be.visible').within(() => {
+        //     // expect to be visible
+        //     cy.getByTestid('remove-bookmark-btn').should('be.visible');
+        //     // take a screenshot for dropdown menu
+        //     cy.screenshot(`${ssPrefix}page-tree-2-after-adding-bookmark`);
+        //   });
+        // });
 
-        it('Successfully show delete page modal', () => {
-          cy.waitUntil(() => {
-            // do
-            cy.getByTestid('grw-sidebar-contents').within(() => {
-              cy.get('.grw-pagetree-item-children').first().as('pagetreeItem').within(() => {
-                cy.getByTestid('open-page-item-control-btn').find('button').first().invoke('css','display','block').click()
-              });
-            });
-            // wait until
-            return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
-          });
+        // it('Successfully show duplicate page modal', () => {
+        //   cy.waitUntil(() => {
+        //     // do
+        //     cy.getByTestid('grw-sidebar-contents').within(() => {
+        //       cy.getByTestid('grw-pagetree-item-container').eq(1).within(() => { // against the second element
+        //         cy.get('li').realHover();
+        //         cy.getByTestid('open-page-item-control-btn').find('button').first().realClick();
+        //       });
+        //     });
+        //     // wait until
+        //     return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
+        //   });
+
+        //   cy.get('.dropdown-menu.show').should('be.visible').within(() => {
+        //     cy.getByTestid('open-page-duplicate-modal-btn').click();
+        //   })
+
+        //   cy.getByTestid('page-duplicate-modal').should('be.visible').within(() => {
+        //     cy.get('.form-control').type('_test');
+
+        //     cy.screenshot(`${ssPrefix}page-tree-5-duplicate-page-modal`, { blackout: blackoutOverride });
+
+        //     cy.get('.modal-header > button').click();
+        //   });
+        // });
 
-          cy.get('.dropdown-menu.show').within(() => {
-            cy.getByTestid('open-page-delete-modal-btn').should('be.visible').click();
-          });
+        // it('Successfully rename page', () => {
+        //   cy.waitUntil(() => {
+        //     // do
+        //     cy.getByTestid('grw-sidebar-contents').within(() => {
+        //       cy.getByTestid('grw-pagetree-item-container').eq(1).within(() => { // against the second element
+        //         cy.get('li').realHover();
+        //         cy.getByTestid('open-page-item-control-btn').find('button').first().realClick();
+        //       });
+        //     });
+        //     // wait until
+        //     return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
+        //   });
+
+        //   cy.get('.dropdown-menu.show').should('be.visible').within(() => {
+        //     cy.getByTestid('rename-page-btn').click();
+        //   })
+
+        //   cy.getByTestid('grw-sidebar-contents').within(() => {
+        //     cy.getByTestid('autosize-submittable-input').type('_newname');
+        //   })
+
+        //   cy.screenshot(`${ssPrefix}page-tree-6-rename-page`, { blackout: blackoutOverride });
+        // });
 
-          cy.getByTestid('page-delete-modal').should('be.visible').within(() => {
-            cy.screenshot(`${ssPrefix}page-tree-7-delete-page-modal`, { blackout: blackoutOverride });
-            cy.get('.modal-header > button').click();
-          });
-        });
+        // it('Successfully show delete page modal', () => {
+        //   cy.waitUntil(() => {
+        //     // do
+        //     cy.getByTestid('grw-sidebar-contents').within(() => {
+        //       cy.getByTestid('grw-pagetree-item-container').eq(1).within(() => { // against the second element
+        //         cy.get('li').realHover();
+        //         cy.getByTestid('open-page-item-control-btn').find('button').first().realClick();
+        //       });
+        //     });
+        //     // wait until
+        //     return cy.get('.dropdown-menu.show').then($elem => $elem.is(':visible'));
+        //   });
+
+        //   cy.get('.dropdown-menu.show').should('be.visible').within(() => {
+        //     cy.getByTestid('open-page-delete-modal-btn').click();
+        //   })
+
+        //   cy.getByTestid('page-delete-modal').should('be.visible').within(() => {
+        //     cy.screenshot(`${ssPrefix}page-tree-7-delete-page-modal`, { blackout: blackoutOverride });
+        //     cy.get('.modal-header > button').click();
+        //   });
+        // });
       });
 
       describe('Test custom sidebar tab', () => {
         beforeEach(() => {
-          cy.getByTestid('grw-sidebar-nav-primary-custom-sidebar').should('be.visible')
-            .then($elem => {
-              // open if inactive
-              if (!$elem.hasClass('active')) {
-                cy.getByTestid('grw-sidebar-nav-primary-custom-sidebar').click();
-              }
-            });
+          cy.getByTestid('grw-sidebar-nav-primary-custom-sidebar').click();
         });
 
         it('Successfully access to custom sidebar', () => {
@@ -230,13 +223,7 @@ describe('Access to sidebar', () => {
 
       describe('Test recent changes tab', () => {
         beforeEach(() => {
-          cy.getByTestid('grw-sidebar-nav-primary-recent-changes').should('be.visible')
-            .then($elem => {
-              // open if inactive
-              if (!$elem.hasClass('active')) {
-                cy.getByTestid('grw-sidebar-nav-primary-recent-changes').click();
-              }
-            });
+          cy.getByTestid('grw-sidebar-nav-primary-recent-changes').click();
         });
 
         it('Successfully access to recent changes', () => {
@@ -249,37 +236,34 @@ describe('Access to sidebar', () => {
 
       });
 
-      describe('Test tags tab', () => {
-        beforeEach(() => {
-          cy.getByTestid('grw-sidebar-nav-primary-tags').should('be.visible')
-            .then($elem => {
-              // open if inactive
-              if (!$elem.hasClass('active')) {
-                cy.getByTestid('grw-sidebar-nav-primary-tags').click();
-              }
-            });
-        });
+      //
+      // Deactivate: An error occurs that cannot be reproduced in the development environment. -- Yuki Takei 2024.05.10
+      //
+      // describe('Test tags tab', () => {
+      //   beforeEach(() => {
+      //     cy.getByTestid('grw-sidebar-nav-primary-tags').click();
+      //   });
 
-        it('Successfully access to tags', () => {
-          cy.getByTestid('grw-sidebar-contents').within(() => {
-            cy.getByTestid('grw-tags-list').should('be.visible');
+      //   it('Successfully access to tags', () => {
+      //     cy.getByTestid('grw-sidebar-contents').within(() => {
+      //       cy.getByTestid('grw-tags-list').should('be.visible');
 
-            cy.screenshot(`${ssPrefix}tags-1-access-to-tags`, { blackout: blackoutOverride });
-          });
-        });
+      //       cy.screenshot(`${ssPrefix}tags-1-access-to-tags`, { blackout: blackoutOverride });
+      //     });
+      //   });
 
-        it('Succesfully click all tags button', () => {
-          cy.getByTestid('grw-sidebar-content-tags').within(() => {
-            cy.get('.btn-primary').as('check-all-tags-button');
-            cy.get('@check-all-tags-button').should('be.visible');
-            cy.get('@check-all-tags-button').click({force: true});
-          });
-          cy.collapseSidebar(true);
-          cy.getByTestid('grw-tags-list').should('be.visible');
+      //   it('Succesfully click all tags button', () => {
+      //     cy.getByTestid('grw-sidebar-content-tags').within(() => {
+      //       cy.get('.btn-primary').as('check-all-tags-button');
+      //       cy.get('@check-all-tags-button').should('be.visible');
+      //       cy.get('@check-all-tags-button').click({force: true});
+      //     });
+      //     cy.collapseSidebar(true);
+      //     cy.getByTestid('grw-tags-list').should('be.visible');
 
-          cy.screenshot(`${ssPrefix}tags-2-click-all-tags-button`, { blackout: blackoutOverride });
-        });
-      });
+      //     cy.screenshot(`${ssPrefix}tags-2-click-all-tags-button`, { blackout: blackoutOverride });
+      //   });
+      // });
 
       // // TODO: No Drafts pages on GROWI version 6
       // it('Successfully access to My Drafts page', () => {

+ 2 - 0
apps/app/test/cypress/support/index.ts

@@ -14,6 +14,8 @@
 // ***********************************************************
 
 // Import commands.js using ES2015 syntax:
+import 'cypress-real-events';
+
 import './assertions'
 import './commands'
 import './screenshot'

+ 1 - 0
apps/app/test/tsconfig.json

@@ -2,5 +2,6 @@
   "extends": "../tsconfig.json",
   "compilerOptions": {
     "isolatedModules": false,
+    "types": ["cypress", "cypress-real-events"],
   },
 }

+ 15 - 28
yarn.lock

@@ -4286,6 +4286,13 @@
   dependencies:
     "@types/react" "*"
 
+"@types/react-input-autosize@^2.2.4":
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/@types/react-input-autosize/-/react-input-autosize-2.2.4.tgz#8c8d4becb14c76cd3337f292e4fe53c9ab3ce1b8"
+  integrity sha512-7O028jRZHZo3mj63h3HSvB0WpvPXNWN86sajHTi0+CtjA4Ym+DFzO9RzrSbfFURe5ZWsq6P72xk7MInI6aGWJA==
+  dependencies:
+    "@types/react" "*"
+
 "@types/react-scroll@^1.8.4":
   version "1.8.4"
   resolved "https://registry.yarnpkg.com/@types/react-scroll/-/react-scroll-1.8.4.tgz#2b6258fb34104d3fcc7a22e8eceaadc669ba3ad1"
@@ -6925,6 +6932,11 @@ currently-unhandled@^0.4.1:
   dependencies:
     array-find-index "^1.0.1"
 
+cypress-real-events@^1.12.0:
+  version "1.12.0"
+  resolved "https://registry.yarnpkg.com/cypress-real-events/-/cypress-real-events-1.12.0.tgz#ffeb2b23686ba5b16ac91dd9bc3b6785d36d38d3"
+  integrity sha512-oiy+4kGKkzc2PT36k3GGQqkGxNiVypheWjMtfyi89iIk6bYmTzeqxapaLHS3pnhZOX1IEbTDUVxh8T4Nhs1tyQ==
+
 cypress-wait-until@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/cypress-wait-until/-/cypress-wait-until-2.0.1.tgz#69c575c7207d83e4ae023e2aaecf2b66148c9fc0"
@@ -16664,7 +16676,7 @@ string-template@>=1.0.0:
   resolved "https://registry.yarnpkg.com/string-template/-/string-template-1.0.0.tgz#9e9f2233dc00f218718ec379a28a5673ecca8b96"
   integrity sha1-np8iM9wA8hhxjsN5oopWc+zKi5Y=
 
-"string-width-cjs@npm:string-width@^4.2.0":
+"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
   integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -16682,15 +16694,6 @@ string-width@=4.2.2:
     is-fullwidth-code-point "^3.0.0"
     strip-ansi "^6.0.0"
 
-"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
-  version "4.2.3"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
-  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
-  dependencies:
-    emoji-regex "^8.0.0"
-    is-fullwidth-code-point "^3.0.0"
-    strip-ansi "^6.0.1"
-
 string-width@^5.0.1, string-width@^5.1.2:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
@@ -16773,7 +16776,7 @@ stringify-entities@^4.0.0:
     character-entities-html4 "^2.0.0"
     character-entities-legacy "^3.0.0"
 
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
   integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -16787,13 +16790,6 @@ strip-ansi@^3.0.0:
   dependencies:
     ansi-regex "^2.0.0"
 
-strip-ansi@^6.0.0, strip-ansi@^6.0.1:
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
-  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
-  dependencies:
-    ansi-regex "^5.0.1"
-
 strip-ansi@^7.0.1:
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -18513,7 +18509,7 @@ word-wrap@^1.2.3:
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
   integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
 
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
   integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -18531,15 +18527,6 @@ wrap-ansi@^6.2.0:
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
 
-wrap-ansi@^7.0.0:
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
-  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
-  dependencies:
-    ansi-styles "^4.0.0"
-    string-width "^4.1.0"
-    strip-ansi "^6.0.0"
-
 wrap-ansi@^8.1.0:
   version "8.1.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"