Răsfoiți Sursa

Merge branch 'dev/7.0.x' into imprv/137432-make-markdown-list-util

WNomunomu 2 ani în urmă
părinte
comite
897e1bfb71
100 a modificat fișierele cu 1524 adăugiri și 653 ștergeri
  1. 1 0
      apps/app/.eslintrc.js
  2. 0 0
      apps/app/_obsolete/src/components/Navbar/GlobalSearch.module.scss
  3. 1 1
      apps/app/_obsolete/src/components/Navbar/GlobalSearch.tsx
  4. 13 5
      apps/app/package.json
  5. 3 0
      apps/app/public/static/locales/en_US/translation.json
  6. 3 0
      apps/app/public/static/locales/ja_JP/translation.json
  7. 3 0
      apps/app/public/static/locales/zh_CN/translation.json
  8. 2 1
      apps/app/src/client/services/side-effects/page-updated.ts
  9. 3 0
      apps/app/src/components/Common/ClosableTextInput.tsx
  10. 37 0
      apps/app/src/components/Common/Dropdown/PageItemControl.spec.tsx
  11. 3 5
      apps/app/src/components/Common/Dropdown/PageItemControl.tsx
  12. 8 10
      apps/app/src/components/Hotkeys/Subscribers/FocusToGlobalSearch.jsx
  13. 1 3
      apps/app/src/components/ItemsTree/ItemsTree.module.scss
  14. 1 1
      apps/app/src/components/ItemsTree/ItemsTree.tsx
  15. 1 1
      apps/app/src/components/Layout/SearchResultLayout.tsx
  16. 0 15
      apps/app/src/components/Me/ColorModeSettings.module.scss
  17. 34 26
      apps/app/src/components/Me/ColorModeSettings.tsx
  18. 21 14
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  19. 68 37
      apps/app/src/components/Navbar/hooks.tsx
  20. 39 14
      apps/app/src/components/PageComment/CommentEditor.tsx
  21. 7 3
      apps/app/src/components/PageDeleteModal.tsx
  22. 0 2
      apps/app/src/components/PageEditor/EditorNavbarBottom.module.scss
  23. 5 5
      apps/app/src/components/PageEditor/EditorNavbarBottom.tsx
  24. 66 55
      apps/app/src/components/PageEditor/OptionsSelector.tsx
  25. 39 37
      apps/app/src/components/PageEditor/PageEditor.tsx
  26. 28 0
      apps/app/src/components/PageHeader/PageHeader.tsx
  27. 130 0
      apps/app/src/components/PageHeader/PagePathHeader.tsx
  28. 35 0
      apps/app/src/components/PageHeader/PageTitleHeader.tsx
  29. 76 0
      apps/app/src/components/PageHeader/TextInputForPageTitleAndPath.tsx
  30. 55 0
      apps/app/src/components/PageHeader/page-header-utils.ts
  31. 8 9
      apps/app/src/components/PageSelectModal/PageSelectModal.tsx
  32. 5 4
      apps/app/src/components/PageSelectModal/TreeItemForModal.tsx
  33. 2 2
      apps/app/src/components/PageSideContents/PageAccessoriesControl.tsx
  34. 1 1
      apps/app/src/components/PageTags/RenderTagLabels.tsx
  35. 1 1
      apps/app/src/components/PrivateLegacyPages.tsx
  36. 3 3
      apps/app/src/components/SavePageControls.tsx
  37. 2 2
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  38. 28 40
      apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx
  39. 8 9
      apps/app/src/components/Sidebar/PageCreateButton/DropendToggle.tsx
  40. 15 7
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  41. 11 4
      apps/app/src/components/Sidebar/PageCreateButton/hooks.tsx
  42. 0 2
      apps/app/src/components/Sidebar/PageTree/PageTreeSubstance.tsx
  43. 19 3
      apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  44. 30 21
      apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.module.scss
  45. 73 37
      apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  46. 4 1
      apps/app/src/components/Sidebar/Sidebar.tsx
  47. 0 4
      apps/app/src/components/Sidebar/SidebarNav/SidebarNav.module.scss
  48. 1 3
      apps/app/src/components/Sidebar/Tag.tsx
  49. 2 2
      apps/app/src/components/TagCloudBox.tsx
  50. 27 0
      apps/app/src/components/TagList.module.scss
  51. 11 6
      apps/app/src/components/TagList.tsx
  52. 43 33
      apps/app/src/components/TreeItem/SimpleItem.tsx
  53. 3 1
      apps/app/src/components/TreeItem/interfaces/index.ts
  54. 0 1
      apps/app/src/interfaces/websocket.ts
  55. 26 5
      apps/app/src/pages/[[...path]].page.tsx
  56. 6 1
      apps/app/src/pages/admin/audit-log.page.tsx
  57. 4 19
      apps/app/src/pages/share/[[...path]].page.tsx
  58. 18 0
      apps/app/src/pages/utils/commons.ts
  59. 3 0
      apps/app/src/server/crowi/index.js
  60. 1 0
      apps/app/src/server/models/obsolete-page.js
  61. 25 10
      apps/app/src/server/service/growi-bridge/index.ts
  62. 22 0
      apps/app/src/server/service/growi-bridge/unzip-stream-utils.ts
  63. 9 4
      apps/app/src/server/service/import.js
  64. 12 0
      apps/app/src/server/service/normalize-data/index.ts
  65. 31 0
      apps/app/src/server/service/normalize-data/rename-duplicate-root-pages.ts
  66. 4 0
      apps/app/src/server/service/page/index.ts
  67. 22 0
      apps/app/src/server/service/socket-io.js
  68. 73 0
      apps/app/src/server/service/yjs-connection-manager.ts
  69. 0 1
      apps/app/src/stores/context.tsx
  70. 2 5
      apps/app/src/stores/ui.tsx
  71. 1 1
      apps/app/src/stores/use-static-swr.ts
  72. 1 7
      apps/app/src/stores/websocket.tsx
  73. 0 46
      apps/app/src/styles/_mixins.scss
  74. 0 31
      apps/app/src/styles/_tag.scss
  75. 0 1
      apps/app/src/styles/_variables.scss
  76. 18 0
      apps/app/src/styles/atoms/_tag.scss
  77. 18 6
      apps/app/src/styles/organisms/_wiki.scss
  78. 1 1
      apps/app/src/styles/style-app.scss
  79. 55 50
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--comments.cy.ts
  80. 10 10
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--sticky-features.cy.ts
  81. 3 3
      apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--access-to-page.cy.ts
  82. 2 2
      apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--sticky-for-guest.cy.ts
  83. 16 0
      apps/app/vitest.config.components.ts
  84. 1 1
      apps/app/vitest.config.ts
  85. 1 1
      package.json
  86. 1 1
      packages/core/scss/bootstrap/apply.scss
  87. 59 0
      packages/core/scss/bootstrap/mixins/_button-outline-neutral-variant.scss
  88. 14 0
      packages/core/scss/bootstrap/override/_buttons.scss
  89. 12 0
      packages/core/scss/bootstrap/override/_helpers.scss
  90. 8 0
      packages/core/scss/bootstrap/override/helpers/_color-bg.scss
  91. 9 9
      packages/core/scss/bootstrap/theming/_variables.scss
  92. 1 1
      packages/core/scss/bootstrap/utilities.scss
  93. 1 0
      packages/core/src/interfaces/index.ts
  94. 1 0
      packages/core/src/interfaces/page.ts
  95. 6 0
      packages/core/src/interfaces/websocket.ts
  96. 1 0
      packages/core/src/swr/index.ts
  97. 11 0
      packages/core/src/swr/use-global-socket.ts
  98. 2 2
      packages/core/src/utils/page-path-utils/index.ts
  99. 11 2
      packages/editor/package.json
  100. 22 2
      packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx

+ 1 - 0
apps/app/.eslintrc.js

@@ -27,6 +27,7 @@ module.exports = {
       },
     ]],
     '@typescript-eslint/no-var-requires': 'off',
+    '@typescript-eslint/consistent-type-imports': 'warn',
 
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei
     '@typescript-eslint/no-use-before-define': ['warn'],

+ 0 - 0
apps/app/src/components/Navbar/GlobalSearch.module.scss → apps/app/_obsolete/src/components/Navbar/GlobalSearch.module.scss


+ 1 - 1
apps/app/src/components/Navbar/GlobalSearch.tsx → apps/app/_obsolete/src/components/Navbar/GlobalSearch.tsx

@@ -17,7 +17,7 @@ import {
 import { useCurrentPagePath } from '~/stores/page';
 import { useGlobalSearchFormRef } from '~/stores/ui';
 
-import SearchForm from '../SearchForm';
+import SearchForm from '../../../../src/components/SearchForm';
 
 import styles from './GlobalSearch.module.scss';
 

+ 13 - 5
apps/app/package.json

@@ -35,11 +35,12 @@
     "prelint:swagger2openapi": "yarn openapi:v3",
     "test": "run-p test:*",
     "test:jest": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
-    "test:vitest": "run-p vitest:run vitest:run:integ",
+    "test:vitest": "run-p vitest:run vitest:run:integ vitest:run:components",
     "jest:run": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
     "reg:run": "reg-suit run",
     "vitest:run": "vitest run config src --coverage",
     "vitest:run:integ": "vitest run -c vitest.config.integ.ts src --coverage",
+    "vitest:run:components": "vitest run -c vitest.config.components.ts src --coverage",
     "previtest:run:integ": "vitest run -c test-with-vite/download-mongo-binary/vitest.config.ts test-with-vite/download-mongo-binary",
     "//// misc": "",
     "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
@@ -195,7 +196,7 @@
     "remark-toc": "^8.0.1",
     "remark-wiki-link": "^1.0.4",
     "sanitize-filename": "^1.6.3",
-    "socket.io": "^4.2.0",
+    "socket.io": "^4.7.2",
     "stream-to-promise": "^3.0.0",
     "string-width": "=4.2.2",
     "superjson": "^1.9.1",
@@ -206,12 +207,14 @@
     "universal-bunyan": "^0.9.2",
     "unstated": "^2.1.1",
     "unzip-stream": "^0.3.1",
-    "unzipper": "^0.10.5",
     "url-join": "^4.0.0",
     "usehooks-ts": "^2.6.0",
     "validator": "^13.7.0",
     "ws": "^8.3.0",
-    "xss": "^1.0.14"
+    "xss": "^1.0.14",
+    "y-mongodb-provider": "^0.1.7",
+    "y-socket.io": "^1.1.0",
+    "yjs": "^13.6.7"
   },
   "// comments for defDependencies": {
     "@handsontable/react": "v3 requires handsontable >= 7.0.0.",
@@ -226,12 +229,15 @@
     "@next/bundle-analyzer": "^13.2.3",
     "@swc-node/jest": "^1.6.2",
     "@swc/jest": "^0.2.24",
+    "@testing-library/react": "^14.1.2",
+    "@testing-library/user-event": "^14.5.2",
     "@types/express": "^4.17.11",
     "@types/jest": "^29.5.2",
     "@types/react-scroll": "^1.8.4",
     "@types/throttle-debounce": "^5.0.1",
     "@types/url-join": "^4.0.2",
     "@types/unzip-stream": "^0.3.4",
+    "@vitejs/plugin-react": "^4.2.1",
     "@vitest/coverage-v8": "^0.34.6",
     "autoprefixer": "^9.0.0",
     "babel-loader": "^8.2.5",
@@ -247,6 +253,7 @@
     "font-awesome": "^4.7.0",
     "fslightbox-react": "^1.7.6",
     "handsontable": "=6.2.2",
+    "happy-dom": "^13.2.0",
     "i18next-hmr": "^1.11.0",
     "jest": "^29.5.0",
     "jest-date-mock": "^1.0.8",
@@ -272,6 +279,7 @@
     "socket.io-client": "^4.2.0",
     "source-map-loader": "^4.0.1",
     "swagger2openapi": "^7.0.8",
-    "tsc-alias": "^1.2.9"
+    "tsc-alias": "^1.2.9",
+    "y-codemirror.next": "^0.3.2"
   }
 }

+ 3 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -819,5 +819,8 @@
   },
   "rich_attachment": {
     "attachment_not_be_found": "The attachment could not be found"
+  },
+  "page_select_modal": {
+    "select_page_location": "Select page location"
   }
 }

+ 3 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -852,5 +852,8 @@
   },
   "rich_attachment": {
     "attachment_not_be_found": "アタッチメントが見つかりません"
+  },
+  "page_select_modal": {
+    "select_page_location": "ページの場所を選択"
   }
 }

+ 3 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -822,5 +822,8 @@
   },
   "rich_attachment": {
     "attachment_not_be_found": "没有找到附件"
+  },
+  "page_select_modal": {
+    "select_page_location": "选择页面位置"
   }
 }

+ 2 - 1
apps/app/src/client/services/side-effects/page-updated.ts

@@ -1,9 +1,10 @@
 import { useCallback, useEffect } from 'react';
 
+import { useGlobalSocket } from '@growi/core/dist/swr';
+
 import { SocketEventName } from '~/interfaces/websocket';
 import { useCurrentPageId } from '~/stores/page';
 import { useSetRemoteLatestPageData, type RemoteRevisionData } from '~/stores/remote-latest-page';
-import { useGlobalSocket } from '~/stores/websocket';
 
 export const usePageUpdatedEffect = (): void => {
 

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

@@ -12,6 +12,7 @@ type ClosableTextInputProps = {
   validationTarget?: string,
   onPressEnter?(inputText: string | null): void
   onClickOutside?(): void
+  handleInputChange?: (string) => void
 }
 
 const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextInputProps) => {
@@ -38,6 +39,8 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
     createValidation(inputText);
     setInputText(inputText);
     setIsAbleToShowAlert(true);
+
+    props.handleInputChange?.(inputText);
   };
 
   const onFocusHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {

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

@@ -0,0 +1,37 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event';
+
+import { PageItemControl } from './PageItemControl';
+
+
+describe('PageItemControl.tsx', () => {
+  it('Should trigger onClickRenameMenuItem() when clicking the rename button with pageInfo.isDeletable being "false"', async() => {
+    // setup
+    const onClickRenameMenuItemMock = vi.fn();
+
+    const pageInfo = {
+      isMovable: true,
+      isV5Compatible: true,
+      isEmpty: false,
+      isDeletable: false,
+      isAbleToDeleteCompletely: true,
+      isRevertible: true,
+    };
+
+    const props = {
+      pageId: 'dummy-page-id',
+      isEnableActions: true,
+      pageInfo,
+      onClickRenameMenuItem: onClickRenameMenuItemMock,
+    };
+
+    render(<PageItemControl {...props} />);
+
+    // when
+    const openPageMoveRenameModalButton = screen.getByTestId('open-page-move-rename-modal-btn');
+    await waitFor(() => userEvent.click(openPageMoveRenameModalButton, { pointerEventsCheck: PointerEventsCheckLevel.Never }));
+
+    // then
+    expect(onClickRenameMenuItemMock).toHaveBeenCalled();
+  });
+});

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

@@ -85,8 +85,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     if (onClickRenameMenuItem == null) {
       return;
     }
-
-    if (!pageInfo?.isDeletable) {
+    if (!pageInfo?.isMovable) {
       logger.warn('This page could not be renamed.');
       return;
     }
@@ -177,10 +176,9 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Move/Rename */}
-        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser && (
+        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser && pageInfo.isMovable && (
           <DropdownItem
             onClick={renameItemClickedHandler}
-            disabled={!pageInfo.isDeletable}
             data-testid="open-page-move-rename-modal-btn"
             className="grw-page-control-dropdown-item"
           >
@@ -232,7 +230,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* divider */}
         {/* Delete */}
-        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser && (
+        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser && pageInfo.isDeletable && (
           <>
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem

+ 8 - 10
apps/app/src/components/Hotkeys/Subscribers/FocusToGlobalSearch.jsx

@@ -1,11 +1,12 @@
 import { useEffect } from 'react';
 
+import { useSearchModal } from '~/features/search/client/stores/search';
 import { useIsEditable } from '~/stores/context';
-import { useGlobalSearchFormRef } from '~/stores/ui';
+
 
 const FocusToGlobalSearch = (props) => {
   const { data: isEditable } = useIsEditable();
-  const { data: globalSearchFormRef } = useGlobalSearchFormRef();
+  const { data: searchModalData, open: openSearchModal } = useSearchModal();
 
   // setup effect
   useEffect(() => {
@@ -13,16 +14,13 @@ const FocusToGlobalSearch = (props) => {
       return;
     }
 
-    // ignore when dom that has 'modal in' classes exists
-    if (document.getElementsByClassName('modal in').length > 0) {
-      return;
+    if (!searchModalData.isOpened) {
+      openSearchModal();
+      // remove this
+      props.onDeleteRender();
     }
 
-    globalSearchFormRef.current.focus();
-
-    // remove this
-    props.onDeleteRender();
-  }, [globalSearchFormRef, isEditable, props]);
+  }, [isEditable, openSearchModal, props, searchModalData.isOpened]);
 
   return null;
 };

+ 1 - 3
apps/app/src/components/ItemsTree/ItemsTree.module.scss

@@ -65,9 +65,7 @@ $grw-pagetree-item-container-height: 40px;
 
     .grw-pagetree-item-container {
       .grw-triangle-container {
-        // TODO: ignore width frickering
-        // https://redmine.weseek.co.jp/issues/130828
-        // min-width: 35px;
+        min-width: 35px;
         height: $grw-pagetree-item-container-height;
       }
     }

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

@@ -5,6 +5,7 @@ import React, {
 import path from 'path';
 
 import type { Nullable, IPageHasId, IPageToDeleteWithMeta } from '@growi/core';
+import { useGlobalSocket } from '@growi/core/dist/swr';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { debounce } from 'throttle-debounce';
@@ -22,7 +23,6 @@ import {
 } from '~/stores/page-listing';
 import { mutateSearching } from '~/stores/search';
 import { usePageTreeDescCountMap, useSidebarScrollerRef } from '~/stores/ui';
-import { useGlobalSocket } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 
 import { ItemNode, type TreeItemProps } from '../TreeItem';

+ 1 - 1
apps/app/src/components/Layout/SearchResultLayout.tsx

@@ -1,4 +1,4 @@
-import React, { ReactNode } from 'react';
+import React, { type ReactNode } from 'react';
 
 import { BasicLayout } from '~/components/Layout/BasicLayout';
 

+ 0 - 15
apps/app/src/components/Me/ColorModeSettings.module.scss

@@ -1,15 +0,0 @@
-@use '@growi/core/scss/bootstrap/init' as *;
-
-.color-settings :global {
-  .btn {
-    font-weight: bold;
-    color: var(--color-global);
-    background-color: transparent;
-    border-width: 3px;
-  }
-
-  .btn-outline-secondary {
-    border-color: $gray-400;
-  }
-}
-

+ 34 - 26
apps/app/src/components/Me/ColorModeSettings.tsx

@@ -4,7 +4,27 @@ import { useTranslation } from 'react-i18next';
 
 import { Themes, useNextThemes } from '~/stores/use-next-themes';
 
-import styles from './ColorModeSettings.module.scss';
+// import styles from './ColorModeSettings.module.scss';
+
+
+type ColorModeSettingsButtonProps = {
+  isActive: boolean,
+  children?: React.ReactNode,
+  onClick?: () => void,
+}
+
+const ColorModeSettingsButton = ({ isActive, children, onClick }: ColorModeSettingsButtonProps): JSX.Element => {
+  return (
+    <button
+      type="button"
+      onClick={onClick}
+      className={`btn py-2 px-4 fw-bold border-3 ${isActive ? 'btn-outline-primary' : 'btn-outline-neutral-secondary'}`}
+    >
+      { children }
+    </button>
+  );
+};
+
 
 export const ColorModeSettings = (): JSX.Element => {
   const { t } = useTranslation();
@@ -16,40 +36,28 @@ export const ColorModeSettings = (): JSX.Element => {
   }, [theme]);
 
   return (
-    <div className={`color-settings ${styles['color-settings']}`}>
+    <div>
       <h2 className="border-bottom mb-4">{t('color_mode_settings.settings')}</h2>
 
       <div className="offset-md-3">
-        <div className="d-flex">
-          <button
-            type="button"
-            onClick={() => { setTheme(Themes.LIGHT) }}
-            // eslint-disable-next-line max-len
-            className={`btn py-2 px-4 me-4 d-flex align-items-center justify-content-center ${isActive(Themes.LIGHT) ? 'btn-outline-primary' : 'btn-outline-secondary'}`}
-          >
+
+        <div className="d-flex column-gap-3">
+
+          <ColorModeSettingsButton isActive={isActive(Themes.LIGHT)} onClick={() => { setTheme(Themes.LIGHT) }}>
             <span className="material-symbols-outlined fs-5 me-1">light_mode</span>
             <span>{t('color_mode_settings.light')}</span>
-          </button>
-
-          <button
-            type="button"
-            onClick={() => { setTheme(Themes.DARK) }}
-            // eslint-disable-next-line max-len
-            className={`btn py-2 px-4 me-4 d-flex align-items-center justify-content-center ${isActive(Themes.DARK) ? 'btn-outline-primary' : 'btn-outline-secondary'}`}
-          >
+          </ColorModeSettingsButton>
+
+          <ColorModeSettingsButton isActive={isActive(Themes.DARK)} onClick={() => { setTheme(Themes.DARK) }}>
             <span className="material-symbols-outlined fs-5 me-1">dark_mode</span>
             <span>{t('color_mode_settings.dark')}</span>
-          </button>
-
-          <button
-            type="button"
-            onClick={() => { setTheme(Themes.SYSTEM) }}
-            // eslint-disable-next-line max-len
-            className={`btn py-2 px-4 d-flex align-items-center justify-content-center ${isActive(Themes.SYSTEM) ? 'btn-outline-primary' : 'btn-outline-secondary'}`}
-          >
+          </ColorModeSettingsButton>
+
+          <ColorModeSettingsButton isActive={isActive(Themes.SYSTEM)} onClick={() => { setTheme(Themes.SYSTEM) }}>
             <span className="material-symbols-outlined fs-5 me-1">devices</span>
             <span>{t('color_mode_settings.system')}</span>
-          </button>
+          </ColorModeSettingsButton>
+
         </div>
 
         <div className="mt-3 text-muted">

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

@@ -3,9 +3,10 @@ import React, { type ReactNode, useCallback, useState } from 'react';
 import type { IGrantedGroup } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
-import { EditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
+import { toastError } from '~/client/util/toastr';
+import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 
-import { useOnPageEditorModeButtonClicked } from './hooks';
+import { useCreatePageAndTransit } from './hooks';
 
 import styles from './PageEditorModeManager.module.scss';
 
@@ -15,7 +16,7 @@ type PageEditorModeButtonProps = {
   editorMode: EditorMode,
   children?: ReactNode,
   isBtnDisabled?: boolean,
-  onClick?: (mode: EditorMode) => void,
+  onClick?: () => void,
 }
 const PageEditorModeButton = React.memo((props: PageEditorModeButtonProps) => {
   const {
@@ -34,7 +35,7 @@ const PageEditorModeButton = React.memo((props: PageEditorModeButtonProps) => {
     <button
       type="button"
       className={classNames.join(' ')}
-      onClick={() => onClick?.(editorMode)}
+      onClick={onClick}
       data-testid={`${editorMode}-button`}
     >
       {children}
@@ -60,21 +61,27 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
     // grantUserGroupId,
   } = props;
 
-  const { t } = useTranslation();
+  const { t } = useTranslation('common');
   const [isCreating, setIsCreating] = useState(false);
 
+  const { mutate: mutateEditorMode } = useEditorMode();
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
 
-  const onPageEditorModeButtonClicked = useOnPageEditorModeButtonClicked(setIsCreating, path);
   const _isBtnDisabled = isCreating || isBtnDisabled;
 
-  const pageEditorModeButtonClickedHandler = useCallback((viewType: EditorMode) => {
-    if (_isBtnDisabled) {
-      return;
-    }
+  const createPageAndTransit = useCreatePageAndTransit();
 
-    onPageEditorModeButtonClicked?.(viewType);
-  }, [_isBtnDisabled, onPageEditorModeButtonClicked]);
+  const editButtonClickedHandler = useCallback(() => {
+    createPageAndTransit(
+      path,
+      {
+        onCreationStart: () => { setIsCreating(true) },
+        onAborted: () => { mutateEditorMode(EditorMode.Editor) },
+        onError: () => { toastError(t('toaster.create_failed', { target: path })) },
+        onTerminated: () => { setIsCreating(false) },
+      },
+    );
+  }, [createPageAndTransit, path, mutateEditorMode, t]);
 
   return (
     <>
@@ -89,7 +96,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
             currentEditorMode={editorMode}
             editorMode={EditorMode.View}
             isBtnDisabled={_isBtnDisabled}
-            onClick={pageEditorModeButtonClickedHandler}
+            onClick={() => mutateEditorMode(EditorMode.View)}
           >
             <span className="material-symbols-outlined fs-4">play_arrow</span>{t('View')}
           </PageEditorModeButton>
@@ -99,7 +106,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
             currentEditorMode={editorMode}
             editorMode={EditorMode.Editor}
             isBtnDisabled={_isBtnDisabled}
-            onClick={pageEditorModeButtonClickedHandler}
+            onClick={editButtonClickedHandler}
           >
             <span className="material-symbols-outlined me-1 fs-5">edit_square</span>{t('Edit')}
           </PageEditorModeButton>

+ 68 - 37
apps/app/src/components/Navbar/hooks.tsx

@@ -1,58 +1,89 @@
 import { useCallback } from 'react';
 
-import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 
 import { createPage } from '~/client/services/page-operation';
-import { toastError } from '~/client/util/toastr';
 import { useIsNotFound } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:Navbar:GrowiContextualSubNavigation');
 
-export const useOnPageEditorModeButtonClicked = (
-    setIsCreating:React.Dispatch<React.SetStateAction<boolean>>,
-    path?: string,
-    // grant?: number,
-    // grantUserGroupId?: string,
-): (editorMode: EditorMode) => Promise<void> => {
+/**
+ * Invoked when creation and transition has finished
+ */
+type OnCreated = () => void;
+/**
+ * Invoked when either creation or transition has aborted
+ */
+type OnAborted = () => void;
+/**
+ * Invoked when an error is occured
+ */
+type OnError = (err) => void;
+/**
+ * Always invoked after processing is terminated
+ */
+type OnTerminated = () => void;
+
+type CreatePageAndTransitOpts = {
+  onCreationStart?: OnCreated,
+  onCreated?: OnCreated,
+  onAborted?: OnAborted,
+  onError?: OnError,
+  onTerminated?: OnTerminated,
+}
+
+type CreatePageAndTransit = (
+  pagePath: string | undefined,
+  // grant?: number,
+  // grantUserGroupId?: string,
+  opts?: CreatePageAndTransitOpts,
+) => Promise<void>;
+
+export const useCreatePageAndTransit = (): CreatePageAndTransit => {
+
   const router = useRouter();
-  const { t } = useTranslation('commons');
+
   const { data: isNotFound } = useIsNotFound();
   const { mutate: mutateEditorMode } = useEditorMode();
 
-  return useCallback(async(editorMode: EditorMode) => {
-    if (isNotFound == null || path == null) {
+  return useCallback(async(pagePath, opts = {}) => {
+    const {
+      onCreationStart, onCreated, onAborted, onError, onTerminated,
+    } = opts;
+
+    if (isNotFound == null || !isNotFound || pagePath == null) {
+      onAborted?.();
+      onTerminated?.();
       return;
     }
 
-    if (editorMode === EditorMode.Editor && isNotFound) {
-      try {
-        setIsCreating(true);
-
-        const params = {
-          isSlackEnabled: false,
-          slackChannels: '',
-          grant: 4,
-          // grant,
-          // grantUserGroupId,
-        };
-
-        const response = await createPage(path, '', params);
-
-        // Should not mutateEditorMode as it might prevent transitioning during mutation
-        router.push(`${response.page.id}#edit`);
-      }
-      catch (err) {
-        logger.warn(err);
-        toastError(t('toaster.create_failed', { target: path }));
-      }
-      finally {
-        setIsCreating(false);
-      }
+    try {
+      onCreationStart?.();
+
+      const params = {
+        isSlackEnabled: false,
+        slackChannels: '',
+        grant: 4,
+        // grant,
+        // grantUserGroupId,
+      };
+
+      const response = await createPage(pagePath, '', params);
+
+      await router.push(`${response.page.id}#edit`);
+      mutateEditorMode(EditorMode.Editor);
+
+      onCreated?.();
+    }
+    catch (err) {
+      logger.warn(err);
+      onError?.(err);
+    }
+    finally {
+      onTerminated?.();
     }
 
-    mutateEditorMode(editorMode);
-  }, [isNotFound, mutateEditorMode, path, router, setIsCreating, t]);
+  }, [isNotFound, mutateEditorMode, router]);
 };

+ 39 - 14
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -2,7 +2,9 @@ import React, {
   useCallback, useState, useRef, useEffect,
 } from 'react';
 
-import { useResolvedThemeForEditor } from '@growi/editor';
+import {
+  CodeMirrorEditorComment, GlobalCodeMirrorEditorKey, useCodeMirrorEditorIsolated, useResolvedThemeForEditor,
+} from '@growi/editor';
 import { UserPicture } from '@growi/ui/dist/components';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
@@ -12,7 +14,7 @@ import {
 
 import { apiPostForm } from '~/client/util/apiv1-client';
 import { toastError } from '~/client/util/toastr';
-import { IEditorMethods } from '~/interfaces/editor-methods';
+import type { IEditorMethods } from '~/interfaces/editor-methods';
 import { useSWRxPageComment, useSWRxEditingCommentsNum } from '~/stores/comment';
 import {
   useCurrentUser, useIsSlackConfigured,
@@ -25,7 +27,6 @@ import { useNextThemes } from '~/stores/use-next-themes';
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import { NotAvailableForGuest } from '../NotAvailableForGuest';
 import { NotAvailableForReadOnlyUser } from '../NotAvailableForReadOnlyUser';
-import Editor from '../PageEditor/Editor';
 
 import { CommentPreview } from './CommentPreview';
 
@@ -79,9 +80,9 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     decrement: decrementEditingCommentsNum,
   } = useSWRxEditingCommentsNum();
   const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
-
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.COMMENT);
   const { resolvedTheme } = useNextThemes();
-  mutateResolvedTheme(resolvedTheme);
+  mutateResolvedTheme({ themeData: resolvedTheme });
 
   const [isReadyToUse, setIsReadyToUse] = useState(!isForNewComment);
   const [comment, setComment] = useState(commentBody ?? '');
@@ -143,6 +144,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     if (editingCommentsNum != null && editingCommentsNum === 0) {
       mutateIsEnabledUnsavedWarning(false); // must be after clearing comment or else onChange will override bool
     }
+
   }, [initializeSlackEnabled, comment, decrementEditingCommentsNum, mutateIsEnabledUnsavedWarning]);
 
   const cancelButtonClickedHandler = useCallback(() => {
@@ -186,15 +188,17 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
       if (onCommentButtonClicked != null) {
         onCommentButtonClicked();
       }
+
+      // Insert empty string as new comment editor is opened after comment
+      codeMirrorEditor?.initDoc('');
     }
     catch (err) {
       const errorMessage = err.message || 'An unknown error occured when posting comment';
       setError(errorMessage);
     }
   }, [
-    comment, currentCommentId, initializeEditor,
-    isSlackEnabled, onCommentButtonClicked, replyTo, slackChannels,
-    postComment, revisionId, updateComment,
+    currentCommentId, initializeEditor, onCommentButtonClicked, codeMirrorEditor,
+    updateComment, comment, revisionId, replyTo, isSlackEnabled, slackChannels, postComment,
   ]);
 
   const ctrlEnterHandler = useCallback((event) => {
@@ -267,14 +271,32 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     );
   }, []);
 
-  const onChangeHandler = useCallback((newValue: string, isClean: boolean) => {
+  // const onChangeHandler = useCallback((newValue: string, isClean: boolean) => {
+  //   setComment(newValue);
+  //   if (!isClean && !incremented) {
+  //     incrementEditingCommentsNum();
+  //     setIncremented(true);
+  //   }
+  //   mutateIsEnabledUnsavedWarning(!isClean);
+  // }, [mutateIsEnabledUnsavedWarning, incrementEditingCommentsNum, incremented]);
+
+  const onChangeHandler = useCallback((newValue: string) => {
     setComment(newValue);
-    if (!isClean && !incremented) {
+
+    if (!incremented) {
       incrementEditingCommentsNum();
       setIncremented(true);
     }
-    mutateIsEnabledUnsavedWarning(!isClean);
-  }, [mutateIsEnabledUnsavedWarning, incrementEditingCommentsNum, incremented]);
+  }, [incrementEditingCommentsNum, incremented]);
+
+  // initialize CodeMirrorEditor
+  useEffect(() => {
+    if (commentBody == null) {
+      return;
+    }
+    codeMirrorEditor?.initDoc(commentBody);
+  }, [codeMirrorEditor, commentBody]);
+
 
   const renderReady = () => {
     const commentPreview = getCommentHtml();
@@ -311,7 +333,10 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
           <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={handleSelect} hideBorderBottom />
           <TabContent activeTab={activeTab}>
             <TabPane tabId="comment_editor">
-              <Editor
+              <CodeMirrorEditorComment
+                onChange={onChangeHandler}
+              />
+              {/* <Editor
                 ref={editorRef}
                 value={commentBody ?? ''} // DO NOT use state
                 isUploadable={isUploadable}
@@ -320,7 +345,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
                 onUpload={uploadHandler}
                 onCtrlEnter={ctrlEnterHandler}
                 isComment
-              />
+              /> */}
               {/*
                 Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.
                 See a review comment in https://github.com/weseek/growi/pull/3473

+ 7 - 3
apps/app/src/components/PageDeleteModal.tsx

@@ -2,7 +2,6 @@ import React, {
   useState, FC, useMemo, useEffect,
 } from 'react';
 
-import { isIPageInfoForEntity } from '@growi/core';
 import type {
   HasObjectId,
   IPageInfoForEntity, IPageToDeleteWithMeta, IDataWithMeta,
@@ -42,6 +41,11 @@ const deleteIconAndKey = {
   },
 };
 
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const isIPageInfoForEntityForDeleteModal = (pageInfo: any | undefined): pageInfo is IPageInfoForEntity => {
+  return pageInfo != null && 'isDeletable' in pageInfo && 'isAbleToDeleteCompletely' in pageInfo;
+};
+
 const PageDeleteModal: FC = () => {
   const { t } = useTranslation();
 
@@ -50,14 +54,14 @@ const PageDeleteModal: FC = () => {
   const isOpened = deleteModalData?.isOpened ?? false;
 
   const notOperatablePages: IPageToDeleteWithMeta[] = (deleteModalData?.pages ?? [])
-    .filter(p => !isIPageInfoForEntity(p.meta));
+    .filter(p => !isIPageInfoForEntityForDeleteModal(p.meta));
   const notOperatablePageIds = notOperatablePages.map(p => p.data._id);
 
   const { injectTo } = useSWRxPageInfoForList(notOperatablePageIds);
 
   // inject IPageInfo to operate
   let injectedPages: IDataWithMeta<HasObjectId & { path: string }, IPageInfoForEntity>[] | null = null;
-  if (deleteModalData?.pages != null && notOperatablePageIds.length > 0) {
+  if (deleteModalData?.pages != null) {
     injectedPages = injectTo(deleteModalData?.pages);
   }
 

+ 0 - 2
apps/app/src/components/PageEditor/EditorNavbarBottom.module.scss

@@ -4,8 +4,6 @@
 
 @include mixins.editing() {
   .grw-editor-navbar-bottom :global {
-    height: var.$grw-editor-navbar-bottom-height;
-
     .grw-grant-selector {
       @include bs.media-breakpoint-down(sm) {
         .btn .label {

+ 5 - 5
apps/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -4,12 +4,12 @@ import dynamic from 'next/dynamic';
 import { Collapse, Button } from 'reactstrap';
 
 
-import { SavePageControlsProps } from '~/components/SavePageControls';
+import type { SavePageControlsProps } from '~/components/SavePageControls';
 import { useIsSlackConfigured } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
 import {
-  useDrawerOpened, useEditorMode, useIsDeviceLargerThanLg, useIsDeviceLargerThanMd,
+  useEditorMode, useIsDeviceLargerThanLg, useIsDeviceLargerThanMd,
 } from '~/stores/ui';
 
 
@@ -73,7 +73,7 @@ const EditorNavbarBottom = (): JSX.Element => {
   const isCollapsedOptionsSelectorEnabled = !isDeviceLargerThanLg;
 
   return (
-    <div className={`${isCollapsedOptionsSelectorEnabled ? 'fixed-bottom' : ''} `}>
+    <div className={`${isCollapsedOptionsSelectorEnabled ? 'fixed-bottom' : ''} `} data-testid="grw-editor-navbar-bottom">
       {/* Collapsed SlackNotification */}
       {isSlackConfigured && (
         <Collapse isOpen={isSlackExpanded && !isDeviceLargerThanLg}>
@@ -93,11 +93,11 @@ const EditorNavbarBottom = (): JSX.Element => {
         </Collapse>
       )
       }
-      <div className={`flex-expand-horiz align-items-center border-top px-2 px-md-3 ${moduleClass}`}>
+      <div className={`flex-expand-horiz align-items-center px-2 px-md-3 ${moduleClass}`}>
         <form>
           { isDeviceLargerThanMd && <OptionsSelector /> }
         </form>
-        <form className="flex-nowrap ms-auto">
+        <form className="row row-cols-lg-auto g-3 align-items-center ms-auto">
           {/* Responsive Design for the SlackNotification */}
           {/* Button or the normal Slack banner */}
           {isSlackConfigured && (!isDeviceLargerThanMd ? (

+ 66 - 55
apps/app/src/components/PageEditor/OptionsSelector.tsx

@@ -14,7 +14,7 @@ import { DEFAULT_THEME, KeyMapMode } from '../../interfaces/editor-settings';
 
 
 const AVAILABLE_THEMES = [
-  'eclipse', 'elegant', 'neo', 'mdn-like', 'material', 'dracula', 'monokai', 'twilight',
+  'DefaultLight', 'Eclipse', 'Basic', 'Ayu', 'Rosé Pine', 'DefaultDark', 'Material', 'Nord', 'Cobalt', 'Kimbie',
 ];
 
 const TYPICAL_INDENT_SIZE = [2, 4];
@@ -22,12 +22,18 @@ const TYPICAL_INDENT_SIZE = [2, 4];
 
 const ThemeSelector = (): JSX.Element => {
 
+  const [isThemeMenuOpened, setIsThemeMenuOpened] = useState(false);
+
   const { data: editorSettings, update } = useEditorSettings();
 
   const menuItems = useMemo(() => (
     <>
       { AVAILABLE_THEMES.map((theme) => {
-        return <button key={theme} className="dropdown-item" type="button" onClick={() => update({ theme })}>{theme}</button>;
+        return (
+          <DropdownItem className="menuitem-label" onClick={() => update({ theme })}>
+            {theme}
+          </DropdownItem>
+        );
       }) }
     </>
   ), [update]);
@@ -39,21 +45,21 @@ const ThemeSelector = (): JSX.Element => {
       <div>
         <span className="input-group-text" id="igt-theme">Theme</span>
       </div>
-      <div className="dropup">
-        <button
-          type="button"
-          className="btn btn-outline-secondary dropdown-toggle"
-          data-bs-toggle="dropdown"
-          aria-haspopup="true"
-          aria-expanded="false"
-          aria-describedby="igt-theme"
-        >
+
+      <Dropdown
+        direction="up"
+        isOpen={isThemeMenuOpened}
+        toggle={() => setIsThemeMenuOpened(!isThemeMenuOpened)}
+      >
+        <DropdownToggle color="outline-secondary" caret>
           {selectedTheme}
-        </button>
-        <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
+        </DropdownToggle>
+
+        <DropdownMenu container="body">
           {menuItems}
-        </div>
-      </div>
+        </DropdownMenu>
+
+      </Dropdown>
     </div>
   );
 };
@@ -72,9 +78,10 @@ const KEYMAP_LABEL_MAP: KeyMapModeToLabel = {
 
 const KeymapSelector = memo((): JSX.Element => {
 
+  const [isKeyMenuOpened, setIsKeyMenuOpened] = useState(false);
+
   const { data: editorSettings, update } = useEditorSettings();
 
-  Object.keys(KEYMAP_LABEL_MAP);
   const menuItems = useMemo(() => (
     <>
       { (Object.keys(KEYMAP_LABEL_MAP) as KeyMapMode[]).map((keymapMode) => {
@@ -82,7 +89,11 @@ const KeymapSelector = memo((): JSX.Element => {
         const icon = (keymapMode !== 'default')
           ? <img src={`/images/icons/${keymapMode}.png`} width="16px" className="me-2"></img>
           : null;
-        return <button key={keymapMode} className="dropdown-item" type="button" onClick={() => update({ keymapMode })}>{icon}{keymapLabel}</button>;
+        return (
+          <DropdownItem className="menuitem-label" onClick={() => update({ keymapMode })}>
+            {icon}{keymapLabel}
+          </DropdownItem>
+        );
       }) }
     </>
   ), [update]);
@@ -91,24 +102,21 @@ const KeymapSelector = memo((): JSX.Element => {
 
   return (
     <div className="input-group flex-nowrap">
-      <div>
-        <span className="input-group-text" id="igt-keymap">Keymap</span>
-      </div>
-      <div className="dropup">
-        <button
-          type="button"
-          className="btn btn-outline-secondary dropdown-toggle"
-          data-bs-toggle="dropdown"
-          aria-haspopup="true"
-          aria-expanded="false"
-          aria-describedby="igt-keymap"
-        >
-          { editorSettings != null && selectedKeymapMode}
-        </button>
-        <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
+      <span className="input-group-text" id="igt-keymap">Keymap</span>
+      <Dropdown
+        direction="up"
+        isOpen={isKeyMenuOpened}
+        toggle={() => setIsKeyMenuOpened(!isKeyMenuOpened)}
+      >
+        <DropdownToggle color="outline-secondary" caret>
+          {selectedKeymapMode}
+        </DropdownToggle>
+
+        <DropdownMenu container="body">
           {menuItems}
-        </div>
-      </div>
+        </DropdownMenu>
+
+      </Dropdown>
     </div>
   );
 
@@ -123,38 +131,41 @@ type IndentSizeSelectorProps = {
 }
 
 const IndentSizeSelector = memo(({ isIndentSizeForced, selectedIndentSize, onChange }: IndentSizeSelectorProps): JSX.Element => {
+
+  const [isIndentMenuOpened, setIsIndentMenuOpened] = useState(false);
+
   const menuItems = useMemo(() => (
     <>
       { TYPICAL_INDENT_SIZE.map((indent) => {
-        return <button key={indent} className="dropdown-item" type="button" onClick={() => onChange(indent)}>{indent}</button>;
+        return (
+          <DropdownItem className="menuitem-label" onClick={() => onChange(indent)}>
+            {indent}
+          </DropdownItem>
+        );
       }) }
     </>
   ), [onChange]);
 
   return (
     <div className="input-group flex-nowrap">
-      <div>
-        <span className="input-group-text" id="igt-indent">Indent</span>
-      </div>
-      <div className="dropup">
-        <button
-          type="button"
-          className="btn btn-outline-secondary dropdown-toggle"
-          data-bs-toggle="dropdown"
-          aria-haspopup="true"
-          aria-expanded="false"
-          aria-describedby="igt-indent"
-          disabled={isIndentSizeForced}
-        >
+      <span className="input-group-text" id="igt-indent">Indent</span>
+      <Dropdown
+        direction="up"
+        isOpen={isIndentMenuOpened}
+        toggle={() => setIsIndentMenuOpened(!isIndentMenuOpened)}
+        disabled={isIndentSizeForced}
+      >
+        <DropdownToggle color="outline-secondary" caret>
           {selectedIndentSize}
-        </button>
-        <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
+        </DropdownToggle>
+
+        <DropdownMenu container="body">
           {menuItems}
-        </div>
-      </div>
+        </DropdownMenu>
+
+      </Dropdown>
     </div>
   );
-
 });
 
 IndentSizeSelector.displayName = 'IndentSizeSelector';
@@ -228,7 +239,7 @@ const ConfigurationDropdown = memo((): JSX.Element => {
           <i className="icon-settings"></i>
         </DropdownToggle>
 
-        <DropdownMenu>
+        <DropdownMenu container="body">
           {renderActiveLineMenuItem()}
           {renderMarkdownTableAutoFormattingMenuItem()}
           {/* <DropdownItem divider /> */}
@@ -254,7 +265,7 @@ export const OptionsSelector = (): JSX.Element => {
 
   return (
     <>
-      <div className="d-flex flex-row">
+      <div className="d-flex flex-row zindex-dropdown">
         <span>
           <ThemeSelector />
         </span>

+ 39 - 37
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -2,10 +2,11 @@ import React, {
   useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
 } from 'react';
 
-import EventEmitter from 'events';
+import type EventEmitter from 'events';
 import nodePath from 'path';
 
 import type { IPageHasId } from '@growi/core';
+import { useGlobalSocket } from '@growi/core/dist/swr';
 import { pathUtils } from '@growi/core/dist/utils';
 import {
   CodeMirrorEditorMain, GlobalCodeMirrorEditorKey, AcceptedUploadFileType,
@@ -16,18 +17,20 @@ import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { throttle, debounce } from 'throttle-debounce';
 
+
 import { useShouldExpandContent } from '~/client/services/layout';
 import { useUpdateStateAfterSave, useSaveOrUpdate } from '~/client/services/page-operation';
 import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import { OptionsToSave } from '~/interfaces/page-operation';
+import type { OptionsToSave } from '~/interfaces/page-operation';
 import { SocketEventName } from '~/interfaces/websocket';
 import {
-  useDefaultIndentSize,
+  useDefaultIndentSize, useCurrentUser,
   useCurrentPathname, useIsEnabledAttachTitleHeader,
   useIsEditable, useIsUploadAllFileAllowed, useIsUploadEnabled, useIsIndentSizeForced,
 } from '~/stores/context';
 import {
+  useEditorSettings,
   useCurrentIndentSize, useIsSlackEnabled, usePageTagsForEditors,
   useIsEnabledUnsavedWarning,
   useIsConflict,
@@ -51,9 +54,9 @@ import {
   useEditorMode, useSelectedGrant,
 } from '~/stores/ui';
 import { useNextThemes } from '~/stores/use-next-themes';
-import { useGlobalSocket } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 
+import { PageHeader } from '../PageHeader/PageHeader';
 
 // import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
 // import { ConflictDiffModal } from './ConflictDiffModal';
@@ -111,11 +114,13 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: isUploadAllFileAllowed } = useIsUploadAllFileAllowed();
   const { data: isUploadEnabled } = useIsUploadEnabled();
   const { data: conflictDiffModalStatus, close: closeConflictDiffModal } = useConflictDiffModal();
+  const { data: editorSettings } = useEditorSettings();
   const { mutate: mutateIsLatestRevision } = useIsLatestRevision();
   const { mutate: mutateRemotePageId } = useRemoteRevisionId();
   const { mutate: mutateRemoteRevisionId } = useRemoteRevisionBody();
   const { mutate: mutateRemoteRevisionLastUpdatedAt } = useRemoteRevisionLastUpdatedAt();
   const { mutate: mutateRemoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdateUser();
+  const { data: user } = useCurrentUser();
 
   const { data: socket } = useGlobalSocket();
 
@@ -131,7 +136,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
 
   const { resolvedTheme } = useNextThemes();
-  mutateResolvedTheme(resolvedTheme);
+  mutateResolvedTheme({ themeData: resolvedTheme });
 
   // TODO: remove workaround
   // for https://redmine.weseek.co.jp/issues/125923
@@ -170,15 +175,16 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const setMarkdownPreviewWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string) => {
     setMarkdownToPreview(value);
   })), []);
-  const mutateIsEnabledUnsavedWarningWithDebounce = useMemo(() => debounce(600, throttle(900, (value: string) => {
-    // Displays an unsaved warning alert
-    mutateIsEnabledUnsavedWarning(value !== initialValueRef.current);
-  })), [mutateIsEnabledUnsavedWarning]);
+  // const mutateIsEnabledUnsavedWarningWithDebounce = useMemo(() => debounce(600, throttle(900, (value: string) => {
+  //   // Displays an unsaved warning alert
+  //   mutateIsEnabledUnsavedWarning(value !== initialValueRef.current);
+  // })), [mutateIsEnabledUnsavedWarning]);
 
   const markdownChangedHandler = useCallback((value: string) => {
     setMarkdownPreviewWithDebounce(value);
-    mutateIsEnabledUnsavedWarningWithDebounce(value);
-  }, [mutateIsEnabledUnsavedWarningWithDebounce, setMarkdownPreviewWithDebounce]);
+    // mutateIsEnabledUnsavedWarningWithDebounce(value);
+  // }, [mutateIsEnabledUnsavedWarningWithDebounce, setMarkdownPreviewWithDebounce]);
+  }, [setMarkdownPreviewWithDebounce]);
 
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
@@ -403,17 +409,6 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   }, [mutateCurrentPage, mutateEditingMarkdown, mutateIsConflict, mutateTagsInfo, syncTagsInfoForEditor]);
 
-
-  // initialize
-  useEffect(() => {
-    if (initialValue == null) {
-      return;
-    }
-    codeMirrorEditor?.initDoc(initialValue);
-    setMarkdownToPreview(initialValue);
-    mutateIsEnabledUnsavedWarning(false);
-  }, [codeMirrorEditor, initialValue, mutateIsEnabledUnsavedWarning]);
-
   // initial caret line
   useEffect(() => {
     codeMirrorEditor?.setCaretLine();
@@ -452,19 +447,21 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     }
   }, [initialValue, isIndentSizeForced, mutateCurrentIndentSize]);
 
-  // when transitioning to a different page, if the initialValue is the same,
-  // UnControlled CodeMirror value does not reset, so explicitly set the value to initialValue
-  const onRouterChangeComplete = useCallback(() => {
-    codeMirrorEditor?.initDoc(initialValue);
-    codeMirrorEditor?.setCaretLine();
-  }, [codeMirrorEditor, initialValue]);
 
-  useEffect(() => {
-    router.events.on('routeChangeComplete', onRouterChangeComplete);
-    return () => {
-      router.events.off('routeChangeComplete', onRouterChangeComplete);
-    };
-  }, [onRouterChangeComplete, router.events]);
+  // TODO: Check the reproduction conditions that made this code necessary and confirm reproduction
+  // // when transitioning to a different page, if the initialValue is the same,
+  // // UnControlled CodeMirror value does not reset, so explicitly set the value to initialValue
+  // const onRouterChangeComplete = useCallback(() => {
+  //   codeMirrorEditor?.initDoc(ydoc?.getText('codemirror').toString());
+  //   codeMirrorEditor?.setCaretLine();
+  // }, [codeMirrorEditor, ydoc]);
+
+  // useEffect(() => {
+  //   router.events.on('routeChangeComplete', onRouterChangeComplete);
+  //   return () => {
+  //     router.events.off('routeChangeComplete', onRouterChangeComplete);
+  //   };
+  // }, [onRouterChangeComplete, router.events]);
 
   if (!isEditable) {
     return <></>;
@@ -476,8 +473,8 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   return (
     <div data-testid="page-editor" id="page-editor" className={`flex-expand-vert ${props.visibility ? '' : 'd-none'}`}>
-      <div className="flex-expand-vert justify-content-center align-items-center" style={{ minHeight: '72px' }}>
-        <div>Header</div>
+      <div className="flex-expand-vert justify-content-center" style={{ minHeight: '72px' }}>
+        <PageHeader />
       </div>
       <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
         <div className="page-editor-editor-container flex-expand-vert">
@@ -497,9 +494,14 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             onChange={markdownChangedHandler}
             onSave={saveWithShortcut}
             onUpload={uploadHandler}
+            acceptedFileType={acceptedFileType}
             onScroll={scrollEditorHandlerThrottle}
             indentSize={currentIndentSize ?? defaultIndentSize}
-            acceptedFileType={acceptedFileType}
+            userName={user?.name}
+            pageId={pageId ?? undefined}
+            initialValue={initialValue}
+            onOpenEditor={markdown => setMarkdownToPreview(markdown)}
+            editorTheme={editorSettings?.theme}
           />
         </div>
         <div ref={previewRef} onScroll={scrollPreviewHandlerThrottle} className="page-editor-preview-container flex-expand-vert d-none d-lg-flex">

+ 28 - 0
apps/app/src/components/PageHeader/PageHeader.tsx

@@ -0,0 +1,28 @@
+import { FC } from 'react';
+
+import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
+
+import { PagePathHeader } from './PagePathHeader';
+import { PageTitleHeader } from './PageTitleHeader';
+
+export const PageHeader: FC = () => {
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: currentPage } = useSWRxCurrentPage();
+
+  if (currentPage == null || currentPagePath == null) {
+    return <></>;
+  }
+
+  return (
+    <>
+      <PagePathHeader
+        currentPagePath={currentPagePath}
+        currentPage={currentPage}
+      />
+      <PageTitleHeader
+        currentPagePath={currentPagePath}
+        currentPage={currentPage}
+      />
+    </>
+  );
+};

+ 130 - 0
apps/app/src/components/PageHeader/PagePathHeader.tsx

@@ -0,0 +1,130 @@
+import {
+  FC, useEffect, useMemo, useState,
+} from 'react';
+
+import type { IPagePopulatedToShowRevision } from '@growi/core';
+
+import { usePageSelectModal } from '~/stores/modal';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+
+import { PagePathNav } from '../Common/PagePathNav';
+import { PageSelectModal } from '../PageSelectModal/PageSelectModal';
+
+import { TextInputForPageTitleAndPath } from './TextInputForPageTitleAndPath';
+import { usePagePathRenameHandler } from './page-header-utils';
+
+type Props = {
+  currentPagePath: string
+  currentPage: IPagePopulatedToShowRevision
+}
+
+export const PagePathHeader: FC<Props> = (props) => {
+  const { currentPagePath, currentPage } = props;
+
+  const [isRenameInputShown, setRenameInputShown] = useState(false);
+  const [isButtonsShown, setButtonShown] = useState(false);
+  const [inputText, setInputText] = useState('');
+
+  const { data: editorMode } = useEditorMode();
+  const { data: PageSelectModalData, open: openPageSelectModal } = usePageSelectModal();
+
+  const onRenameFinish = () => {
+    setRenameInputShown(false);
+  };
+
+  const onRenameFailure = () => {
+    setRenameInputShown(true);
+  };
+
+  const pagePathRenameHandler = usePagePathRenameHandler(currentPage, onRenameFinish, onRenameFailure);
+
+  const stateHandler = { isRenameInputShown, setRenameInputShown };
+
+  const isOpened = PageSelectModalData?.isOpened ?? false;
+
+  const isViewMode = editorMode === EditorMode.View;
+  const isEditorMode = !isViewMode;
+
+  const PagePath = useMemo(() => (
+    <>
+      {currentPagePath != null && (
+        <PagePathNav
+          pageId={currentPage._id}
+          pagePath={currentPagePath}
+          isSingleLineMode={isEditorMode}
+        />
+      )}
+    </>
+  ), [currentPage._id, currentPagePath, isEditorMode]);
+
+  const handleInputChange = (inputText: string) => {
+    setInputText(inputText);
+  };
+
+  const handleEditButtonClick = () => {
+    if (isRenameInputShown) {
+      pagePathRenameHandler(inputText);
+    }
+    else {
+      setRenameInputShown(true);
+    }
+  };
+
+  const buttonStyle = isButtonsShown ? '' : 'd-none';
+
+  const clickOutSideHandler = (e) => {
+    const container = document.getElementById('page-path-header');
+
+    if (container && !container.contains(e.target)) {
+      setRenameInputShown(false);
+    }
+  };
+
+  useEffect(() => {
+    document.addEventListener('click', clickOutSideHandler);
+
+    return () => {
+      document.removeEventListener('click', clickOutSideHandler);
+    };
+  }, []);
+
+  return (
+    <>
+      <div
+        id="page-path-header"
+        onMouseLeave={() => setButtonShown(false)}
+      >
+        <div className="row">
+          <div
+            className="col-4"
+            onMouseEnter={() => setButtonShown(true)}
+          >
+            <TextInputForPageTitleAndPath
+              currentPage={currentPage}
+              stateHandler={stateHandler}
+              inputValue={currentPagePath}
+              CustomComponent={PagePath}
+              handleInputChange={handleInputChange}
+            />
+          </div>
+          <div className={`${buttonStyle} col-4 row`}>
+            <div className="col-4">
+              <button type="button" onClick={handleEditButtonClick}>
+                {isRenameInputShown ? <span className="material-symbols-outlined">check_circle</span> : <span className="material-symbols-outlined">edit</span>}
+              </button>
+            </div>
+            <div className="col-4">
+              <button type="button" onClick={openPageSelectModal}>
+                <span className="material-symbols-outlined">account_tree</span>
+              </button>
+            </div>
+          </div>
+          {isOpened
+            && (
+              <PageSelectModal />
+            )}
+        </div>
+      </div>
+    </>
+  );
+};

+ 35 - 0
apps/app/src/components/PageHeader/PageTitleHeader.tsx

@@ -0,0 +1,35 @@
+import { FC, useState, useMemo } from 'react';
+
+import nodePath from 'path';
+
+import type { IPagePopulatedToShowRevision } from '@growi/core';
+
+import { TextInputForPageTitleAndPath } from './TextInputForPageTitleAndPath';
+
+type Props = {
+  currentPagePath: string,
+  currentPage: IPagePopulatedToShowRevision;
+}
+
+
+export const PageTitleHeader: FC<Props> = (props) => {
+  const { currentPagePath, currentPage } = props;
+
+  const [isRenameInputShown, setRenameInputShown] = useState(false);
+  const pageName = nodePath.basename(currentPagePath ?? '') || '/';
+
+  const stateHandler = { isRenameInputShown, setRenameInputShown };
+
+  const PageTitle = useMemo(() => (<div onClick={() => setRenameInputShown(true)}>{pageName}</div>), [pageName]);
+
+  return (
+    <div onBlur={() => setRenameInputShown(false)}>
+      <TextInputForPageTitleAndPath
+        currentPage={currentPage}
+        stateHandler={stateHandler}
+        inputValue={pageName}
+        CustomComponent={PageTitle}
+      />
+    </div>
+  );
+};

+ 76 - 0
apps/app/src/components/PageHeader/TextInputForPageTitleAndPath.tsx

@@ -0,0 +1,76 @@
+import { FC, useCallback } from 'react';
+import type { Dispatch, SetStateAction } from 'react';
+
+import nodePath from 'path';
+
+import type { IPagePopulatedToShowRevision } from '@growi/core';
+import { pathUtils } from '@growi/core/dist/utils';
+import { useTranslation } from 'next-i18next';
+
+import { ValidationTarget } from '~/client/util/input-validator';
+
+import ClosableTextInput from '../Common/ClosableTextInput';
+
+
+import { usePagePathRenameHandler } from './page-header-utils';
+
+
+type StateHandler = {
+  isRenameInputShown: boolean
+  setRenameInputShown: Dispatch<SetStateAction<boolean>>
+}
+
+type Props = {
+  currentPage: IPagePopulatedToShowRevision
+  stateHandler: StateHandler
+  inputValue: string
+  CustomComponent: JSX.Element
+  handleInputChange?: (string) => void
+}
+
+export const TextInputForPageTitleAndPath: FC<Props> = (props) => {
+  const {
+    currentPage, stateHandler, inputValue, CustomComponent, handleInputChange,
+  } = props;
+
+  const { t } = useTranslation();
+
+  const { isRenameInputShown, setRenameInputShown } = stateHandler;
+
+  const onRenameFinish = () => {
+    setRenameInputShown(false);
+  };
+
+  const onRenameFailure = () => {
+    setRenameInputShown(true);
+  };
+
+  const pagePathRenameHandler = usePagePathRenameHandler(currentPage, onRenameFinish, onRenameFailure);
+
+  const onPressEnter = useCallback((inputPagePath: string) => {
+
+    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage.path ?? ''));
+    const newPagePath = nodePath.resolve(parentPath, inputPagePath);
+
+    pagePathRenameHandler(newPagePath);
+
+  }, [currentPage.path, pagePathRenameHandler]);
+
+  return (
+    <>
+      {isRenameInputShown ? (
+        <div className="flex-fill">
+          <ClosableTextInput
+            value={inputValue}
+            placeholder={t('Input page name')}
+            onPressEnter={onPressEnter}
+            validationTarget={ValidationTarget.PAGE}
+            handleInputChange={handleInputChange}
+          />
+        </div>
+      ) : (
+        <>{ CustomComponent }</>
+      )}
+    </>
+  );
+};

+ 55 - 0
apps/app/src/components/PageHeader/page-header-utils.ts

@@ -0,0 +1,55 @@
+import { useCallback } from 'react';
+
+import type { IPagePopulatedToShowRevision } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useSWRMUTxCurrentPage } from '~/stores/page';
+import { mutatePageTree, mutatePageList } from '~/stores/page-listing';
+
+export const usePagePathRenameHandler = (
+    currentPage: IPagePopulatedToShowRevision, onRenameFinish?: () => void, onRenameFailure?: () => void,
+): (newPagePath: string) => Promise<void> => {
+
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
+  const { t } = useTranslation();
+
+  const currentPagePath = currentPage.path;
+
+  const pagePathRenameHandler = useCallback(async(newPagePath: string) => {
+
+    const onRenamed = (fromPath: string | undefined, toPath: string) => {
+      mutatePageTree();
+      mutatePageList();
+
+      if (currentPagePath === fromPath || currentPagePath === toPath) {
+        mutateCurrentPage();
+      }
+    };
+
+    if (newPagePath === currentPage.path || newPagePath === '') {
+      onRenameFinish?.();
+      return;
+    }
+
+    try {
+      onRenameFinish?.();
+      await apiv3Put('/pages/rename', {
+        pageId: currentPage._id,
+        revisionId: currentPage.revision._id,
+        newPagePath,
+      });
+
+      onRenamed(currentPage.path, newPagePath);
+
+      toastSuccess(t('renamed_pages', { path: currentPage.path }));
+    }
+    catch (err) {
+      onRenameFailure?.();
+      toastError(err);
+    }
+  }, [currentPage._id, currentPage.path, currentPage.revision._id, currentPagePath, mutateCurrentPage, onRenameFailure, onRenameFinish, t]);
+
+  return pagePathRenameHandler;
+};

+ 8 - 9
apps/app/src/components/PageSelectModal/PageSelectModal.tsx

@@ -1,5 +1,6 @@
-import React from 'react';
+import React, { FC } from 'react';
 
+import { useTranslation } from 'next-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter, Button,
 } from 'reactstrap';
@@ -13,7 +14,7 @@ import { ItemsTree } from '../ItemsTree';
 import { TreeItemForModal } from './TreeItemForModal';
 
 
-export const PageSelectModal = () => {
+export const PageSelectModal: FC = () => {
   const {
     data: PageSelectModalData,
     close: closeModal,
@@ -21,6 +22,8 @@ export const PageSelectModal = () => {
 
   const isOpened = PageSelectModalData?.isOpened ?? false;
 
+  const { t } = useTranslation();
+
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPath } = useCurrentPagePath();
@@ -40,8 +43,9 @@ export const PageSelectModal = () => {
       isOpen={isOpened}
       toggle={() => closeModal()}
       centered
+      size="sm"
     >
-      <ModalHeader toggle={() => closeModal()}>modal</ModalHeader>
+      <ModalHeader toggle={closeModal}>{t('page_select_modal.select_page_location')}</ModalHeader>
       <ModalBody>
         <ItemsTree
           CustomTreeItem={TreeItemForModal}
@@ -53,12 +57,7 @@ export const PageSelectModal = () => {
         />
       </ModalBody>
       <ModalFooter>
-        <Button color="primary">
-          Do Something
-        </Button>{' '}
-        <Button color="secondary">
-          Cancel
-        </Button>
+        <Button color="primary" onClick={closeModal}>{t('Done')}</Button>{' '}
       </ModalFooter>
     </Modal>
   );

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

@@ -1,7 +1,7 @@
 import React, { type FC } from 'react';
 
 import {
-  SimpleItem, SimpleItemTool, useNewPageInput, type TreeItemProps,
+  SimpleItem, useNewPageInput, type TreeItemProps,
 } from '../TreeItem';
 
 type PageTreeItemProps = TreeItemProps & {
@@ -10,6 +10,7 @@ type PageTreeItemProps = TreeItemProps & {
 
 export const TreeItemForModal: FC<PageTreeItemProps> = (props) => {
 
+  const { isOpen } = props;
   const { Input: NewPageInput, CreateButton: NewPageCreateButton } = useNewPageInput();
 
   return (
@@ -17,15 +18,15 @@ export const TreeItemForModal: FC<PageTreeItemProps> = (props) => {
       key={props.key}
       targetPathOrId={props.targetPathOrId}
       itemNode={props.itemNode}
-      isOpen
+      isOpen={isOpen}
       isEnableActions={props.isEnableActions}
       isReadOnlyUser={props.isReadOnlyUser}
-      onRenamed={props.onRenamed}
       onClickDuplicateMenuItem={props.onClickDuplicateMenuItem}
       onClickDeleteMenuItem={props.onClickDeleteMenuItem}
+      onRenamed={props.onRenamed}
       customNextComponents={[NewPageInput]}
       itemClass={TreeItemForModal}
-      customEndComponents={[SimpleItemTool, NewPageCreateButton]}
+      customEndComponents={[NewPageCreateButton]}
     />
   );
 };

+ 2 - 2
apps/app/src/components/PageSideContents/PageAccessoriesControl.tsx

@@ -26,7 +26,7 @@ export const PageAccessoriesControl = memo((props: Props): JSX.Element => {
   return (
     <button
       type="button"
-      className={`btn btn-sm btn-outline-secondary ${moduleClass} ${className} rounded-pill`}
+      className={`btn btn-sm btn-outline-neutral-secondary ${moduleClass} ${className} rounded-pill`}
       onClick={onClick}
     >
       <span className="grw-icon d-flex">{icon}</span>
@@ -34,7 +34,7 @@ export const PageAccessoriesControl = memo((props: Props): JSX.Element => {
         {label}
         {/* Do not display CountBadge if '/trash/*': https://github.com/weseek/growi/pull/7600 */}
         { count != null
-          ? <CountBadge count={count} offset={1} />
+          ? <CountBadge count={count} />
           : <div className="px-2"></div>}
       </span>
     </button>

+ 1 - 1
apps/app/src/components/PageTags/RenderTagLabels.tsx

@@ -30,7 +30,7 @@ const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
           <a
             key={tag}
             type="button"
-            className="grw-tag-label badge bg-primary me-2"
+            className="grw-tag badge me-2"
             onClick={() => pushState(`tag:${tag}`)}
           >
             {tag}

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

@@ -2,6 +2,7 @@ import React, {
   useCallback, useMemo, useRef, useState, useEffect,
 } from 'react';
 
+import { useGlobalSocket } from '@growi/core/dist/swr';
 import { useTranslation } from 'next-i18next';
 import {
   UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem, Modal, ModalHeader, ModalBody, ModalFooter,
@@ -22,7 +23,6 @@ import { mutatePageTree, useSWRxV5MigrationStatus } from '~/stores/page-listing'
 import {
   useSWRxSearch,
 } from '~/stores/search';
-import { useGlobalSocket } from '~/stores/websocket';
 
 import { MenuItemType } from './Common/Dropdown/PageItemControl';
 import PaginationWrapper from './PaginationWrapper';

+ 3 - 3
apps/app/src/components/SavePageControls.tsx

@@ -1,6 +1,6 @@
 import React, { useCallback } from 'react';
 
-import EventEmitter from 'events';
+import type EventEmitter from 'events';
 
 import { isTopPage, isUsersProtectedPages } from '@growi/core/dist/utils/page-path-utils';
 import { useTranslation } from 'next-i18next';
@@ -89,7 +89,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
         )
       }
 
-      <UncontrolledButtonDropdown direction="up">
+      <UncontrolledButtonDropdown direction="up" size="sm">
         <Button
           id="caret"
           data-testid="save-page-btn"
@@ -104,7 +104,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
           {labelSubmitButton}
         </Button>
         <DropdownToggle caret color="primary" disabled={isWaitingSaveProcessing} />
-        <DropdownMenu end>
+        <DropdownMenu container="body" end>
           <DropdownItem onClick={saveAndOverwriteScopesOfDescendants}>
             {labelOverwriteScopes}
           </DropdownItem>

+ 2 - 2
apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx

@@ -139,11 +139,11 @@ export const GrantSelector = (props: Props): JSX.Element => {
 
     return (
       <div className="grw-grant-selector mb-0" data-testid="grw-grant-selector">
-        <UncontrolledDropdown direction="up">
+        <UncontrolledDropdown direction="up" size="sm">
           <DropdownToggle color={dropdownToggleBtnColor} caret className="d-flex justify-content-between align-items-center" disabled={disabled}>
             {dropdownToggleLabelElm}
           </DropdownToggle>
-          <DropdownMenu>
+          <DropdownMenu container="body">
             {dropdownMenuElems}
           </DropdownMenu>
         </UncontrolledDropdown>

+ 28 - 40
apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx

@@ -1,9 +1,11 @@
 import React from 'react';
 
 import { useTranslation } from 'react-i18next';
+import { DropdownMenu, DropdownItem } from 'reactstrap';
 
 import { LabelType } from '~/interfaces/template';
 
+
 type DropendMenuProps = {
   onClickCreateNewPageButtonHandler: () => Promise<void>
   onClickCreateTodaysButtonHandler: () => Promise<void>
@@ -22,52 +24,38 @@ export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element =>
   const { t } = useTranslation('commons');
 
   return (
-    <ul className="dropdown-menu">
-      <li>
-        <button
-          className="dropdown-item"
-          onClick={onClickCreateNewPageButtonHandler}
-          type="button"
-        >
-          {t('create_page_dropdown.new_page')}
-        </button>
-      </li>
+    <DropdownMenu
+      container="body"
+    >
+      <DropdownItem
+        onClick={onClickCreateNewPageButtonHandler}
+      >
+        {t('create_page_dropdown.new_page')}
+      </DropdownItem>
       {todaysPath != null && (
         <>
-          <li><hr className="dropdown-divider" /></li>
+          <DropdownItem divider />
           <li><span className="text-muted px-3">{t('create_page_dropdown.todays.desc')}</span></li>
-          <li>
-            <button
-              className="dropdown-item"
-              onClick={onClickCreateTodaysButtonHandler}
-              type="button"
-            >
-              {todaysPath}
-            </button>
-          </li>
+          <DropdownItem
+            onClick={onClickCreateTodaysButtonHandler}
+          >
+            {todaysPath}
+          </DropdownItem>
         </>
       )}
-      <li><hr className="dropdown-divider" /></li>
+      <DropdownItem divider />
       <li><span className="text-muted text-nowrap px-3">{t('create_page_dropdown.template.desc')}</span></li>
-      <li>
-        <button
-          className="dropdown-item"
-          onClick={() => onClickTemplateButtonHandler('_template')}
-          type="button"
-        >
-          {t('create_page_dropdown.template.children')}
-        </button>
-      </li>
-      <li>
-        <button
-          className="dropdown-item"
-          onClick={() => onClickTemplateButtonHandler('__template')}
-          type="button"
-        >
-          {t('create_page_dropdown.template.descendants')}
-        </button>
-      </li>
-    </ul>
+      <DropdownItem
+        onClick={() => onClickTemplateButtonHandler('_template')}
+      >
+        {t('create_page_dropdown.template.children')}
+      </DropdownItem>
+      <DropdownItem
+        onClick={() => onClickTemplateButtonHandler('__template')}
+      >
+        {t('create_page_dropdown.template.descendants')}
+      </DropdownItem>
+    </DropdownMenu>
   );
 });
 DropendMenu.displayName = 'DropendMenu';

+ 8 - 9
apps/app/src/components/Sidebar/PageCreateButton/DropendToggle.tsx

@@ -1,24 +1,23 @@
-import type { ButtonHTMLAttributes, DetailedHTMLProps } from 'react';
+import { DropdownToggle } from 'reactstrap';
 
 import { Hexagon } from './Hexagon';
 
 import styles from './DropendToggle.module.scss';
 
-const moduleClass = styles['btn-toggle'];
 
+const moduleClass = styles['btn-toggle'];
 
-type Props = DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
 
-export const DropendToggle = (props: Props): JSX.Element => {
+export const DropendToggle = (): JSX.Element => {
   return (
-    <button
-      type="button"
-      {...props}
-      className={`${moduleClass} btn btn-primary ${props.className ?? ''}`}
+    <DropdownToggle
+      color="primary"
+      className={`position-absolute ${moduleClass}`}
+      aria-expanded={false}
     >
       <Hexagon />
       <div className="hitarea position-absolute" />
       <span className="icon material-symbols-outlined position-absolute">chevron_right</span>
-    </button>
+    </DropdownToggle>
   );
 };

+ 15 - 7
apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx

@@ -4,6 +4,7 @@ import type { IUserHasId } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { format } from 'date-fns';
 import { useTranslation } from 'react-i18next';
+import { Dropdown } from 'reactstrap';
 
 import { useOnTemplateButtonClicked } from '~/client/services/use-on-template-button-clicked';
 import { toastError } from '~/client/util/toastr';
@@ -16,6 +17,7 @@ import { DropendMenu } from './DropendMenu';
 import { DropendToggle } from './DropendToggle';
 import { useOnNewButtonClicked, useOnTodaysButtonClicked } from './hooks';
 
+
 const generateTodaysPath = (currentUser: IUserHasId, parentDirName: string) => {
   const now = format(new Date(), 'yyyy/MM/dd');
   const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
@@ -31,6 +33,8 @@ export const PageCreateButton = React.memo((): JSX.Element => {
 
   const [isHovered, setIsHovered] = useState(false);
 
+  const [dropdownOpen, setDropdownOpen] = useState(false);
+
   const todaysPath = currentUser == null
     ? null
     : generateTodaysPath(currentUser, t('create_page_dropdown.todays.memo'));
@@ -58,8 +62,11 @@ export const PageCreateButton = React.memo((): JSX.Element => {
 
   const onMouseLeaveHandler = () => {
     setIsHovered(false);
+    setDropdownOpen(false);
   };
 
+  const toggle = () => setDropdownOpen(!dropdownOpen);
+
   return (
     <div
       className="d-flex flex-row"
@@ -74,19 +81,20 @@ export const PageCreateButton = React.memo((): JSX.Element => {
         />
       </div>
       { isHovered && (
-        <div className="btn-group dropend position-absolute">
-          <DropendToggle
-            className="dropdown-toggle dropdown-toggle-split"
-            data-bs-toggle="dropdown"
-            aria-expanded="false"
-          />
+        <Dropdown
+          isOpen={dropdownOpen}
+          toggle={toggle}
+          direction="end"
+          className="position-absolute"
+        >
+          <DropendToggle />
           <DropendMenu
             onClickCreateNewPageButtonHandler={onClickNewButton}
             onClickCreateTodaysButtonHandler={onClickTodaysButton}
             onClickTemplateButtonHandler={onClickTemplateButtonHandler}
             todaysPath={todaysPath}
           />
-        </div>
+        </Dropdown>
       )}
     </div>
   );

+ 11 - 4
apps/app/src/components/Sidebar/PageCreateButton/hooks.tsx

@@ -5,6 +5,7 @@ import { useRouter } from 'next/router';
 
 import { createPage, exist } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
 export const useOnNewButtonClicked = (
     currentPagePath?: string,
@@ -18,6 +19,8 @@ export const useOnNewButtonClicked = (
   const router = useRouter();
   const [isPageCreating, setIsPageCreating] = useState(false);
 
+  const { mutate: mutateEditorMode } = useEditorMode();
+
   const onClickHandler = useCallback(async() => {
     if (isLoading) return;
 
@@ -45,7 +48,8 @@ export const useOnNewButtonClicked = (
       // !! NOTICE !! - if shouldGeneratePath is flagged, send the parent page path
       const response = await createPage(parentPath, '', params);
 
-      router.push(`/${response.page.id}#edit`);
+      await router.push(`/${response.page.id}#edit`);
+      mutateEditorMode(EditorMode.Editor);
     }
     catch (err) {
       toastError(err);
@@ -53,7 +57,7 @@ export const useOnNewButtonClicked = (
     finally {
       setIsPageCreating(false);
     }
-  }, [currentPageGrant, currentPageGrantedGroups, currentPagePath, isLoading, router]);
+  }, [currentPageGrant, currentPageGrantedGroups, currentPagePath, isLoading, mutateEditorMode, router]);
 
   return { onClickHandler, isPageCreating };
 };
@@ -67,6 +71,8 @@ export const useOnTodaysButtonClicked = (
   const router = useRouter();
   const [isPageCreating, setIsPageCreating] = useState(false);
 
+  const { mutate: mutateEditorMode } = useEditorMode();
+
   const onClickHandler = useCallback(async() => {
     if (todaysPath == null) {
       return;
@@ -88,7 +94,8 @@ export const useOnTodaysButtonClicked = (
         await createPage(todaysPath, '', params);
       }
 
-      router.push(`${todaysPath}#edit`);
+      await router.push(`${todaysPath}#edit`);
+      mutateEditorMode(EditorMode.Editor);
     }
     catch (err) {
       toastError(err);
@@ -96,7 +103,7 @@ export const useOnTodaysButtonClicked = (
     finally {
       setIsPageCreating(false);
     }
-  }, [router, todaysPath]);
+  }, [mutateEditorMode, router, todaysPath]);
 
   return { onClickHandler, isPageCreating };
 };

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

@@ -45,8 +45,6 @@ const PageTreeUnavailable = () => {
 };
 
 export const PageTreeContent = memo(() => {
-  const { t } = useTranslation();
-
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPath } = useCurrentPagePath();

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

@@ -6,12 +6,14 @@ import React, {
 import nodePath from 'path';
 
 import type { IPageHasId } from '@growi/core';
-import { pagePathUtils } from '@growi/core/dist/utils';
+import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
 import { useDrag, useDrop } from 'react-dnd';
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastWarning, toastError } from '~/client/util/toastr';
+import type { IPageForItem } from '~/interfaces/page';
 import { mutatePageTree, useSWRxPageChildren } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';
 
@@ -21,9 +23,12 @@ import {
 
 import { Ellipsis } from './Ellipsis';
 
+
 const logger = loggerFactory('growi:cli:Item');
 
 export const PageTreeItem: FC<TreeItemProps> = (props) => {
+  const router = useRouter();
+
   const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
     const pageTitle = nodePath.basename(droppedPagePath);
     return nodePath.join(newParentPagePath, pageTitle);
@@ -53,6 +58,16 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
 
   const { mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
 
+  const itemSelectedHandler = useCallback((page: IPageForItem) => {
+    if (page.path == null || page._id == null) {
+      return;
+    }
+
+    const link = pathUtils.returnPathForURL(page.path, page._id);
+
+    router.push(link);
+  }, [router]);
+
   const displayDroppedItemByPageId = useCallback((pageId) => {
     const target = document.getElementById(`pagetree-item-${pageId}`);
     if (target == null) {
@@ -158,12 +173,13 @@ export const PageTreeItem: FC<TreeItemProps> = (props) => {
     <SimpleItem
       targetPathOrId={props.targetPathOrId}
       itemNode={props.itemNode}
-      isOpen
+      isOpen={isOpen}
       isEnableActions={props.isEnableActions}
       isReadOnlyUser={props.isReadOnlyUser}
-      onRenamed={props.onRenamed}
+      onClick={itemSelectedHandler}
       onClickDuplicateMenuItem={props.onClickDuplicateMenuItem}
       onClickDeleteMenuItem={props.onClickDeleteMenuItem}
+      onRenamed={props.onRenamed}
       itemRef={itemRef}
       itemClass={PageTreeItem}
       mainClassName={mainClassName}

+ 30 - 21
apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.module.scss

@@ -6,7 +6,7 @@
   transform: translateY(-2px);
 
   .form-check-label::before {
-    padding-left: 19px;
+    padding-left: 5px;
     content: 'L';
   }
 
@@ -17,6 +17,8 @@
 }
 
 .list-group-item :global {
+  font-size: 12px;
+
   .grw-recent-changes-skeleton-small {
     @include grw-skeleton-text($font-size:14px, $line-height:16px);
     max-width: 120px;
@@ -32,26 +34,6 @@
     width: 80px;
   }
 
-  .grw-recent-changes-item-lower {
-    height: 17.5px;
-  }
-  .footstamp-icon {
-    svg {
-      width: 14px;
-      height: 14px;
-      transform: translateY(-3.5px);
-    }
-  }
-
-  .grw-list-counts {
-    height: 14px;
-    font-size: 12px;
-  }
-
-  .grw-formatted-distance-date {
-    font-size: 10px;
-  }
-
   .icon-lock {
     font-size: 14px;
   }
@@ -65,3 +47,30 @@
     max-width: fit-content;
   }
 }
+
+
+.grw-recent-changes-item-lower :global {
+  font-size: 12px;
+
+  .material-symbols-outlined {
+    font-size: 14px;
+  }
+  .grw-formatted-distance-date {
+    font-size: 10px;
+  }
+}
+
+// == Colors
+.grw-former-link a {
+  --bs-link-opacity: .5;
+
+  &:global {
+    &:hover {
+      --bs-link-opacity: 1;
+    }
+  }
+}
+
+.grw-recent-changes-item-lower :global {
+  color: var(--bs-gray-500);
+}

+ 73 - 37
apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx

@@ -2,9 +2,11 @@ import React, {
   memo, useCallback, useEffect,
 } from 'react';
 
-import { isPopulated, type IPageHasId } from '@growi/core';
+import {
+  isPopulated, type IPageHasId,
+} from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
-import { UserPicture, FootstampIcon } from '@growi/ui/dist/components';
+import { UserPicture } from '@growi/ui/dist/components';
 
 import { useKeywordManager } from '~/client/services/search-operation';
 import { PagePathHierarchicalLink } from '~/components/Common/PagePathHierarchicalLink';
@@ -18,6 +20,8 @@ import { SidebarHeaderReloadButton } from '../SidebarHeaderReloadButton';
 
 import styles from './RecentChangesSubstance.module.scss';
 
+const formerLinkClass = styles['grw-former-link'];
+const pageItemLowerClass = styles['grw-recent-changes-item-lower'];
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const logger = loggerFactory('growi:History');
@@ -33,14 +37,18 @@ type PageItemProps = PageItemLowerProps & {
 
 const PageItemLower = memo(({ page }: PageItemLowerProps): JSX.Element => {
   return (
-    <div className="d-flex justify-content-between grw-recent-changes-item-lower pt-1">
-      <div className="d-flex">
-        <div className="footstamp-icon me-1 d-inline-block"><FootstampIcon /></div>
-        <div className="me-2 grw-list-counts d-inline-block">{page.seenUsers.length}</div>
-        <div className="icon-bubble me-1 d-inline-block"></div>
-        <div className="me-2 grw-list-counts d-inline-block">{page.commentCount}</div>
+    <div className={`${pageItemLowerClass} d-flex justify-content-between grw-recent-changes-item-lower`}>
+      <div className="d-flex align-items-center">
+        <div className="">
+          <span className="material-symbols-outlined p-0">footprint</span>
+          <span className="grw-list-counts ms-1">{page.seenUsers.length}</span>
+        </div>
+        <div className="ms-2">
+          <span className="material-symbols-outlined p-0">chat</span>
+          <span className="grw-list-counts ms-1">{page.commentCount}</span>
+        </div>
       </div>
-      <div className="grw-formatted-distance-date small mt-auto" data-vrt-blackout-datetime>
+      <div className="grw-formatted-distance-date mt-auto" data-vrt-blackout-datetime>
         <FormattedDistanceDate id={page._id} date={page.updatedAt} />
       </div>
     </div>
@@ -48,12 +56,42 @@ const PageItemLower = memo(({ page }: PageItemLowerProps): JSX.Element => {
 });
 PageItemLower.displayName = 'PageItemLower';
 
+type PageTagsProps = PageItemProps;
+const PageTags = memo((props: PageTagsProps): JSX.Element => {
+  const { page, isSmall, onClickTag } = props;
+
+  if (isSmall || (page.tags.length === 0)) {
+    return <></>;
+  }
+
+  return (
+    <>
+      { page.tags.map((tag) => {
+        if (!isPopulated(tag)) {
+          return <></>;
+        }
+        return (
+          <a
+            key={tag.name}
+            type="button"
+            className="grw-tag badge me-2"
+            onClick={() => onClickTag?.(tag.name)}
+          >
+            {tag.name}
+          </a>
+        );
+      }) }
+    </>
+  );
+});
+PageTags.displayName = 'PageTags';
+
 const PageItem = memo(({ page, isSmall, onClickTag }: PageItemProps): JSX.Element => {
   const dPagePath = new DevidedPagePath(page.path, false, true);
   const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
   const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
   const FormerLink = () => (
-    <div className="small">
+    <div className={`${formerLinkClass} small`}>
       <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
     </div>
   );
@@ -63,39 +101,37 @@ const PageItem = memo(({ page, isSmall, onClickTag }: PageItemProps): JSX.Elemen
     locked = <span><i className="icon-lock ms-2" /></span>;
   }
 
-  const tags = page.tags;
-  const tagElements = tags.map((tag) => {
-    if (!isPopulated(tag)) {
-      return <></>;
-    }
-    return (
-      <a
-        key={tag.name}
-        type="button"
-        className="grw-tag-label badge bg-primary me-2 small"
-        onClick={() => onClickTag?.(tag.name)}
-      >
-        {tag.name}
-      </a>
-    );
-  });
+  const isTagElementsRendered = !(isSmall || (page.tags.length === 0));
 
   return (
-    <li className={`list-group-item ${styles['list-group-item']} ${isSmall ? 'py-2' : 'py-3'} px-0`}>
+    <li className={`list-group-item ${styles['list-group-item']} py-2 px-0`}>
       <div className="d-flex w-100">
+
         <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
+
         <div className="flex-grow-1 ms-2">
-          { !dPagePath.isRoot && <FormerLink /> }
-          <h5 className={isSmall ? 'my-0 text-truncate' : 'my-2'}>
-            <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
-            {locked}
-          </h5>
-          {!isSmall && (
-            <div className="grw-tag-labels mt-1 mb-2">
-              { tagElements }
+          <div className={`row ${isSmall ? 'gy-0' : 'gy-2'}`}>
+
+            <div className="col-12">
+              { !dPagePath.isRoot && <FormerLink /> }
             </div>
-          )}
-          <PageItemLower page={page} />
+
+            <h6 className={`col-12 ${isSmall ? 'mb-0 text-truncate' : 'mb-0'}`}>
+              <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
+              {locked}
+            </h6>
+
+            { isTagElementsRendered && (
+              <div className="col-12">
+                <PageTags isSmall={isSmall} page={page} onClickTag={onClickTag} />
+              </div>
+            ) }
+
+            <div className="col-12">
+              <PageItemLower page={page} />
+            </div>
+
+          </div>
         </div>
       </div>
     </li>

+ 4 - 1
apps/app/src/components/Sidebar/Sidebar.tsx

@@ -6,6 +6,7 @@ import React, {
 import dynamic from 'next/dynamic';
 
 import { SidebarMode } from '~/interfaces/ui';
+import { useIsSearchPage } from '~/stores/context';
 import {
   useDrawerOpened,
   useCollapsedContentsOpened,
@@ -181,6 +182,8 @@ export const Sidebar = (): JSX.Element => {
     isDrawerMode, isCollapsedMode, isDockMode,
   } = useSidebarMode();
 
+  const { data: isSearchPage } = useIsSearchPage();
+
   // css styles
   const grwSidebarClass = styles['grw-sidebar'];
   // eslint-disable-next-line no-nested-ternary
@@ -204,7 +207,7 @@ export const Sidebar = (): JSX.Element => {
           <span className="material-symbols-outlined">reorder</span>
         </DrawerToggler>
       ) }
-      { sidebarMode != null && !isDockMode() && <AppTitleOnSubnavigation /> }
+      { sidebarMode != null && !isDockMode() && !isSearchPage && <AppTitleOnSubnavigation /> }
       <DrawableContainer className={`${grwSidebarClass} ${modeClass} border-end flex-expand-vh-100`} divProps={{ 'data-testid': 'grw-sidebar' }}>
         <ResizableContainer>
           { sidebarMode != null && !isCollapsedMode() && <AppTitleOnSidebarHead /> }

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

@@ -3,10 +3,6 @@
 @use '~/styles/variables' as var;
 
 .grw-sidebar-nav :global {
-  // set position and z-index to prevent dropdowns covered by other element
-  position: relative;
-  z-index: bs.$zindex-fixed;
-
   width: var.$grw-sidebar-nav-width;
 
   border-right : 1px solid var(--bs-border-color);

+ 1 - 3
apps/app/src/components/Sidebar/Tag.tsx

@@ -81,9 +81,7 @@ const Tag: FC = () => {
 
       <h3 className="my-3">{t('popular_tags')}</h3>
 
-      <div className="text-center">
-        <TagCloudBox tags={tagCloudData} />
-      </div>
+      <TagCloudBox tags={tagCloudData} />
     </div>
   );
 

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

@@ -31,7 +31,7 @@ const TagCloudBox: FC<Props> = memo((props:(Props & typeof defaultProps)) => {
       <a
         key={tag.name}
         type="button"
-        className="grw-tag-label badge bg-primary me-2"
+        className="grw-tag badge me-2"
         onClick={() => pushState(`tag:${tag.name}`)}
       >
         {tagNameFormat}
@@ -40,7 +40,7 @@ const TagCloudBox: FC<Props> = memo((props:(Props & typeof defaultProps)) => {
   });
 
   return (
-    <div className="grw-popular-tag-labels">
+    <div>
       {tagElements}
     </div>
   );

+ 27 - 0
apps/app/src/components/TagList.module.scss

@@ -0,0 +1,27 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+.grw-tag-list :global {
+  .list-group {
+    // remove border
+    --bs-border-width: 0;
+  }
+}
+
+
+// == Colors
+@include bs.color-mode(light) {
+  .grw-tag-list :global {
+    .grw-tag-count {
+      color: var(--bs-gray-600);
+      background-color: var(--grw-highlight-100);
+    }
+  }
+}
+@include bs.color-mode(dark) {
+  .grw-tag-list :global {
+    .grw-tag-count {
+      color: var(--bs-gray-500);
+      background-color: var(--grw-highlight-800);
+    }
+  }
+}

+ 11 - 6
apps/app/src/components/TagList.tsx

@@ -9,6 +9,11 @@ import { IDataTagCount } from '~/interfaces/tag';
 
 import PaginationWrapper from './PaginationWrapper';
 
+import styles from './TagList.module.scss';
+
+const moduleClass = styles['grw-tag-list'];
+
+
 type TagListProps = {
   tagData: IDataTagCount[],
   totalTags: number,
@@ -37,11 +42,11 @@ const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) =
         <button
           key={tag._id}
           type="button"
-          className="list-group-item list-group-item-action d-flex"
+          className="list-group-item list-group-item-action d-flex justify-content-between"
           onClick={() => pushState(`tag:${tag.name}`)}
         >
-          <div className="text-truncate list-tag-name">{tag.name}</div>
-          <div className="ms-4 my-auto py-1 px-2 list-tag-count badge bg-primary">{tag.count}</div>
+          <div className="text-truncate grw-tag badge">{tag.name}</div>
+          <div className="grw-tag-count badge">{tag.count}</div>
         </button>
       );
     });
@@ -52,8 +57,8 @@ const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) =
   }
 
   return (
-    <>
-      <div className="list-group text-start mb-5">
+    <div className={moduleClass}>
+      <div className="list-group list-group-flush mb-5">
         {generateTagList(tagData)}
       </div>
       {isPaginationShown
@@ -68,7 +73,7 @@ const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) =
         />
       )
       }
-    </>
+    </div>
   );
 
 };

+ 43 - 33
apps/app/src/components/TreeItem/SimpleItem.tsx

@@ -1,16 +1,15 @@
 import React, {
   useCallback, useState, useEffect,
-  type FC, type RefObject, type RefCallback,
+  type FC, type RefObject, type RefCallback, type MouseEvent,
 } from 'react';
 
 import nodePath from 'path';
 
 import type { Nullable } from '@growi/core';
-import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
 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';
@@ -40,34 +39,15 @@ const markTarget = (children: ItemNode[], targetPathOrId?: Nullable<string>): vo
 };
 
 
-export const SimpleItemTool: FC<TreeItemToolProps> = (props) => {
+const SimpleItemContent = ({ page }: { page: IPageForItem }) => {
   const { t } = useTranslation();
-  const router = useRouter();
-
-  const { getDescCount } = usePageTreeDescCountMap();
-
-  const { page } = props.itemNode;
 
   const pageName = nodePath.basename(page.path ?? '') || '/';
 
   const shouldShowAttentionIcon = page.processData != null ? shouldRecoverPagePaths(page.processData) : false;
 
-  const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
-
-  const pageTreeItemClickHandler = (e) => {
-    e.preventDefault();
-
-    if (page.path == null || page._id == null) {
-      return;
-    }
-
-    const link = pathUtils.returnPathForURL(page.path, page._id);
-
-    router.push(link);
-  };
-
   return (
-    <>
+    <div className="flex-grow-1 d-flex align-items-center pe-none">
       {shouldShowAttentionIcon && (
         <>
           <i id="path-recovery" className="fa fa-warning mr-2 text-warning"></i>
@@ -78,9 +58,22 @@ export const SimpleItemTool: FC<TreeItemToolProps> = (props) => {
       )}
       {page != null && page.path != null && page._id != null && (
         <div className="grw-pagetree-title-anchor flex-grow-1">
-          <p onClick={pageTreeItemClickHandler} className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{pageName}</p>
+          <p className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{pageName}</p>
         </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} />
@@ -97,7 +90,7 @@ type SimpleItemProps = TreeItemProps & {
 export const SimpleItem: FC<SimpleItemProps> = (props) => {
   const {
     itemNode, targetPathOrId, isOpen: _isOpen = false,
-    onRenamed, onClickDuplicateMenuItem, onClickDeleteMenuItem, isEnableActions, isReadOnlyUser,
+    onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, isEnableActions, isReadOnlyUser,
     itemRef, itemClass, mainClassName,
   } = props;
 
@@ -110,11 +103,22 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
 
   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;
@@ -123,7 +127,7 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
     return currentChildren != null && currentChildren.length > 0;
   }, [currentChildren]);
 
-  const onClickLoadChildren = useCallback(async() => {
+  const onClickLoadChildren = useCallback(() => {
     setIsOpen(!isOpen);
   }, [isOpen]);
 
@@ -155,9 +159,7 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
 
   const ItemClassFixed = itemClass ?? SimpleItem;
 
-  const CustomEndComponents = props.customEndComponents;
-
-  const SimpleItemContent = CustomEndComponents ?? [SimpleItemTool];
+  const EndComponents = props.customEndComponents ?? [SimpleItemTool];
 
   const baseProps: Omit<TreeItemProps, 'itemNode'> = {
     isEnableActions,
@@ -185,10 +187,13 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
     >
       <li
         ref={itemRef}
+        role="button"
         className={`list-group-item list-group-item-action border-0 py-0 pr-3 d-flex align-items-center
         ${page.isTarget ? 'grw-pagetree-current-page-item' : ''}`}
         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
@@ -202,10 +207,14 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
             </button>
           )}
         </div>
-        {SimpleItemContent.map((ItemContent, index) => (
+
+        <SimpleItemContent page={page} />
+
+        {EndComponents.map((EndComponent, index) => (
           // eslint-disable-next-line react/no-array-index-key
-          <ItemContent key={index} {...toolProps} />
+          <EndComponent key={index} {...toolProps} />
         ))}
+
       </li>
 
       {CustomNextComponents?.map((UnderItemContent, index) => (
@@ -220,6 +229,7 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
             itemNode: node,
             itemClass,
             mainClassName,
+            onClick,
           };
 
           return (

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

@@ -1,6 +1,7 @@
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import type { Nullable } from 'vitest';
 
+import type { IPageForItem } from '~/interfaces/page';
 import type { IPageForPageDuplicateModal } from '~/stores/modal';
 
 import type { ItemNode } from '../ItemNode';
@@ -9,9 +10,9 @@ type TreeItemBaseProps = {
   itemNode: ItemNode,
   isEnableActions: boolean,
   isReadOnlyUser: boolean,
-  onRenamed?(fromPath: string | undefined, toPath: string): void,
   onClickDuplicateMenuItem?(pageToDuplicate: IPageForPageDuplicateModal): void,
   onClickDeleteMenuItem?(pageToDelete: IPageToDeleteWithMeta): void,
+  onRenamed?(fromPath: string | undefined, toPath: string): void,
   stateHandlers?: {
     isOpen: boolean,
     setIsOpen: React.Dispatch<React.SetStateAction<boolean>>,
@@ -27,4 +28,5 @@ export type TreeItemProps = TreeItemBaseProps & {
   mainClassName?: string,
   customEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
   customNextComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
+  onClick?(page: IPageForItem): void,
 };

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

@@ -44,7 +44,6 @@ export const SocketEventName = {
   PageCreated: 'page:create',
   PageUpdated: 'page:update',
   PageDeleted: 'page:delete',
-
 } as const;
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 

+ 26 - 5
apps/app/src/pages/[[...path]].page.tsx

@@ -1,5 +1,5 @@
-import React, { ReactNode, useEffect } from 'react';
-
+import type { ReactNode } from 'react';
+import React, { useEffect } from 'react';
 
 import EventEmitter from 'events';
 
@@ -23,6 +23,7 @@ import superjson from 'superjson';
 import { useEditorModeClassName } from '~/client/services/layout';
 import { PageView } from '~/components/Page/PageView';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
+import { SupportedAction, type SupportedActionType } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { EditorConfig } from '~/interfaces/editor-settings';
 import type { IPageGrantData } from '~/interfaces/page';
@@ -58,7 +59,7 @@ import { DisplaySwitcher } from '../components/Page/DisplaySwitcher';
 import type { NextPageWithLayout } from './_app.page';
 import type { CommonProps } from './utils/commons';
 import {
-  getNextI18NextConfig, getServerSideCommonProps, generateCustomTitleForPage, useInitSidebarConfig, skipSSR,
+  getNextI18NextConfig, getServerSideCommonProps, generateCustomTitleForPage, useInitSidebarConfig, skipSSR, addActivity,
 } from './utils/commons';
 
 
@@ -224,12 +225,11 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   const { pageWithMeta } = props;
 
   const pageId = pageWithMeta?.data._id;
-  const pagePath = pageWithMeta?.data.path ?? props.currentPathname;
   const revisionBody = pageWithMeta?.data.revision?.body;
 
   useCurrentPathname(props.currentPathname);
 
-  useSWRxCurrentPage(pageWithMeta?.data ?? null); // store initial data
+  const { data: currentPage } = useSWRxCurrentPage(pageWithMeta?.data ?? null); // store initial data
 
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
@@ -315,6 +315,10 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
     mutateTemplateBodyData(props.templateBodyData);
   }, [props.templateBodyData, mutateTemplateBodyData]);
 
+  // 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;
+
   const title = generateCustomTitleForPage(props, pagePath);
 
   return (
@@ -596,6 +600,22 @@ async function injectNextI18NextConfigurations(context: GetServerSidePropsContex
   props._nextI18Next = nextI18NextConfig._nextI18Next;
 }
 
+const getAction = (props: Props): SupportedActionType => {
+  if (props.isNotCreatable) {
+    return SupportedAction.ACTION_PAGE_NOT_CREATABLE;
+  }
+  if (props.isForbidden) {
+    return SupportedAction.ACTION_PAGE_FORBIDDEN;
+  }
+  if (props.isNotFound) {
+    return SupportedAction.ACTION_PAGE_NOT_FOUND;
+  }
+  if (pagePathUtils.isUsersHomepage(props.pageWithMeta?.data.path ?? '')) {
+    return SupportedAction.ACTION_PAGE_USER_HOME_VIEW;
+  }
+  return SupportedAction.ACTION_PAGE_VIEW;
+};
+
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
   const req = context.req as CrowiRequest;
   const { user } = req;
@@ -639,6 +659,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   injectServerConfigurations(context, props);
   await injectNextI18NextConfigurations(context, props, ['translation']);
 
+  addActivity(context, getAction(props));
   return {
     props,
   };

+ 6 - 1
apps/app/src/pages/admin/audit-log.page.tsx

@@ -8,7 +8,9 @@ import Head from 'next/head';
 import { SupportedActionType } from '~/interfaces/activity';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CommonProps, generateCustomTitle } from '~/pages/utils/commons';
-import { useCurrentUser, useAuditLogEnabled, useAuditLogAvailableActions } from '~/stores/context';
+import {
+  useCurrentUser, useAuditLogEnabled, useAuditLogAvailableActions, useActivityExpirationSeconds,
+} from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
@@ -19,6 +21,7 @@ const ForbiddenPage = dynamic(() => import('~/components/Admin/ForbiddenPage').t
 
 type Props = CommonProps & {
   auditLogEnabled: boolean,
+  activityExpirationSeconds: number,
   auditLogAvailableActions: SupportedActionType[],
 };
 
@@ -26,6 +29,7 @@ type Props = CommonProps & {
 const AdminAuditLogPage: NextPage<Props> = (props) => {
   const { t } = useTranslation('admin');
   useAuditLogEnabled(props.auditLogEnabled);
+  useActivityExpirationSeconds(props.activityExpirationSeconds);
   useAuditLogAvailableActions(props.auditLogAvailableActions);
   useCurrentUser(props.currentUser ?? null);
 
@@ -53,6 +57,7 @@ const injectServerConfigurations = async(context: GetServerSidePropsContext, pro
   const { activityService } = crowi;
 
   props.auditLogEnabled = crowi.configManager.getConfig('crowi', 'app:auditLogEnabled');
+  props.activityExpirationSeconds = crowi.configManager.getConfig('crowi', 'app:activityExpirationSeconds');
   props.auditLogAvailableActions = activityService.getAvailableActions(false);
 };
 

+ 4 - 19
apps/app/src/pages/share/[[...path]].page.tsx

@@ -12,7 +12,8 @@ import { ShareLinkLayout } from '~/components/Layout/ShareLinkLayout';
 import GrowiContextualSubNavigationSubstance from '~/components/Navbar/GrowiContextualSubNavigation';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { ShareLinkPageView } from '~/components/ShareLinkPageView';
-import { SupportedAction, SupportedActionType } from '~/interfaces/activity';
+import type { SupportedActionType } from '~/interfaces/activity';
+import { SupportedAction } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IShareLinkHasId } from '~/interfaces/share-link';
@@ -26,8 +27,9 @@ import { useCurrentPageId, useIsNotFound, useSWRMUTxCurrentPage } from '~/stores
 import loggerFactory from '~/utils/logger';
 
 import type { NextPageWithLayout } from '../_app.page';
+import type { CommonProps } from '../utils/commons';
 import {
-  getServerSideCommonProps, generateCustomTitleForPage, getNextI18NextConfig, CommonProps, skipSSR,
+  getServerSideCommonProps, generateCustomTitleForPage, getNextI18NextConfig, skipSSR, addActivity,
 } from '../utils/commons';
 
 const logger = loggerFactory('growi:next-page:share');
@@ -201,23 +203,6 @@ function getAction(props: Props): SupportedActionType {
 
   return action;
 }
-
-async function addActivity(context: GetServerSidePropsContext, action: SupportedActionType): Promise<void> {
-  const req: CrowiRequest = context.req as CrowiRequest;
-
-  const parameters = {
-    ip: req.ip,
-    endpoint: req.originalUrl,
-    action,
-    user: req.user?._id,
-    snapshot: {
-      username: req.user?.username,
-    },
-  };
-
-  await req.crowi.activityService.createActivity(parameters);
-}
-
 export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
   const req = context.req as CrowiRequest;
   const { crowi, params } = req;

+ 18 - 0
apps/app/src/pages/utils/commons.ts

@@ -5,9 +5,11 @@ import { isServer } from '@growi/core/dist/utils';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import type { SSRConfig, UserConfig } from 'next-i18next';
 
+
 import * as nextI18NextConfig from '^/config/next-i18next.config';
 
 import { detectLocaleFromBrowserAcceptLanguage } from '~/client/util/locale-utils';
+import { type SupportedActionType } from '~/interfaces/activity';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
@@ -183,3 +185,19 @@ export const skipSSR = async(page: PageDocument, ssrMaxRevisionBodyLength: numbe
 
   return ssrMaxRevisionBodyLength < latestRevisionBodyLength;
 };
+
+export const addActivity = async(context: GetServerSidePropsContext, action: SupportedActionType): Promise<void> => {
+  const req = context.req as CrowiRequest;
+
+  const parameters = {
+    ip: req.ip,
+    endpoint: req.originalUrl,
+    action,
+    user: req.user?._id,
+    snapshot: {
+      username: req.user?.username,
+    },
+  };
+
+  await req.crowi.activityService.createActivity(parameters);
+};

+ 3 - 0
apps/app/src/server/crowi/index.js

@@ -29,6 +29,7 @@ import { instanciate as instanciateExternalAccountService } from '../service/ext
 import { FileUploader, getUploader } from '../service/file-uploader'; // eslint-disable-line no-unused-vars
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { InstallerService } from '../service/installer';
+import { normalizeData } from '../service/normalize-data';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageOperationService from '../service/page-operation';
@@ -172,6 +173,8 @@ Crowi.prototype.init = async function() {
   ]);
 
   await this.autoInstall();
+
+  await normalizeData();
 };
 
 /**

+ 1 - 0
apps/app/src/server/models/obsolete-page.js

@@ -2,6 +2,7 @@ import { PageGrant, GroupType } from '@growi/core';
 import { templateChecker, pagePathUtils, pathUtils } from '@growi/core/dist/utils';
 import escapeStringRegexp from 'escape-string-regexp';
 
+import { Comment } from '~/features/comment/server/models/comment';
 import ExternalUserGroup from '~/features/external-user-group/server/models/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import loggerFactory from '~/utils/logger';

+ 25 - 10
apps/app/src/server/service/growi-bridge.js → apps/app/src/server/service/growi-bridge/index.ts

@@ -1,9 +1,14 @@
+import { Model } from 'mongoose';
+import unzipStream, { type Entry } from 'unzip-stream';
+
 import loggerFactory from '~/utils/logger';
 
+import { tapStreamDataByPromise } from './unzip-stream-utils';
+
 const fs = require('fs');
 const path = require('path');
+
 const streamToPromise = require('stream-to-promise');
-const unzipper = require('unzipper');
 
 const logger = loggerFactory('growi:services:GrowiBridgeService'); // eslint-disable-line no-unused-vars
 
@@ -13,6 +18,14 @@ const logger = loggerFactory('growi:services:GrowiBridgeService'); // eslint-dis
  */
 class GrowiBridgeService {
 
+  crowi: any;
+
+  encoding: string;
+
+  metaFileName: string;
+
+  baseDir: null;
+
   constructor(crowi) {
     this.crowi = crowi;
     this.encoding = 'utf-8';
@@ -47,7 +60,7 @@ class GrowiBridgeService {
    * @return {object} instance of mongoose model
    */
   getModelFromCollectionName(collectionName) {
-    const Model = Object.values(this.crowi.models).find((m) => {
+    const Model = Object.values(this.crowi.models).find((m: Model<unknown>) => {
       return m.collection != null && m.collection.name === collectionName;
     });
 
@@ -84,18 +97,20 @@ class GrowiBridgeService {
    */
   async parseZipFile(zipFile) {
     const fileStat = fs.statSync(zipFile);
-    const innerFileStats = [];
+    const innerFileStats: Array<{ fileName: string, collectionName: string, size: number }> = [];
     let meta = {};
 
     const readStream = fs.createReadStream(zipFile);
-    const unzipStream = readStream.pipe(unzipper.Parse());
+    const unzipStreamPipe = readStream.pipe(unzipStream.Parse());
+    let tapPromise;
 
-    unzipStream.on('entry', async(entry) => {
+    const unzipEntryStream = unzipStreamPipe.on('entry', (entry: Entry) => {
       const fileName = entry.path;
-      const size = entry.vars.uncompressedSize; // There is also compressedSize;
-
+      const size = entry.size; // might be undefined in some archives
       if (fileName === this.getMetaFileName()) {
-        meta = JSON.parse((await entry.buffer()).toString());
+        tapPromise = tapStreamDataByPromise(entry).then((metaBuffer) => {
+          meta = JSON.parse(metaBuffer.toString());
+        });
       }
       else {
         innerFileStats.push({
@@ -104,12 +119,12 @@ class GrowiBridgeService {
           size,
         });
       }
-
       entry.autodrain();
     });
 
     try {
-      await streamToPromise(unzipStream);
+      await streamToPromise(unzipEntryStream);
+      await tapPromise;
     }
     // if zip is broken
     catch (err) {

+ 22 - 0
apps/app/src/server/service/growi-bridge/unzip-stream-utils.ts

@@ -0,0 +1,22 @@
+import { PassThrough } from 'stream';
+
+import type { Entry } from 'unzip-stream';
+
+export const tapStreamDataByPromise = (entry: Entry): Promise<Buffer> => {
+  return new Promise((resolve, reject) => {
+    const buffers: Array<Buffer> = [];
+
+    const entryContentGetterStream = new PassThrough()
+      .on('data', (chunk) => {
+        buffers.push(Buffer.from(chunk));
+      })
+      .on('end', () => {
+        resolve(Buffer.concat(buffers));
+      })
+      .on('error', reject);
+
+    entry
+      .pipe(entryContentGetterStream)
+      .on('error', reject);
+  });
+};

+ 9 - 4
apps/app/src/server/service/import.js

@@ -1,3 +1,8 @@
+/**
+ * @typedef {import("@types/unzip-stream").Parse} Parse
+ * @typedef {import("@types/unzip-stream").Entry} Entry
+ */
+
 import gc from 'expose-gc/function';
 
 import loggerFactory from '~/utils/logger';
@@ -11,7 +16,7 @@ const parseISO = require('date-fns/parseISO');
 const isIsoDate = require('is-iso-date');
 const mongoose = require('mongoose');
 const streamToPromise = require('stream-to-promise');
-const unzipper = require('unzipper');
+const unzipStream = require('unzip-stream');
 
 const CollectionProgressingStatus = require('../models/vo/collection-progressing-status');
 const { createBatchStream } = require('../util/batch-stream');
@@ -386,10 +391,10 @@ class ImportService {
    */
   async unzip(zipFile) {
     const readStream = fs.createReadStream(zipFile);
-    const unzipStream = readStream.pipe(unzipper.Parse());
+    const unzipStreamPipe = readStream.pipe(unzipStream.Parse());
     const files = [];
 
-    unzipStream.on('entry', (entry) => {
+    unzipStreamPipe.on('entry', (/** @type {Entry} */ entry) => {
       const fileName = entry.path;
       // https://regex101.com/r/mD4eZs/6
       // prevent from unexpecting attack doing unzip file (path traversal attack)
@@ -412,7 +417,7 @@ class ImportService {
       }
     });
 
-    await streamToPromise(unzipStream);
+    await streamToPromise(unzipStreamPipe);
 
     return files;
   }

+ 12 - 0
apps/app/src/server/service/normalize-data/index.ts

@@ -0,0 +1,12 @@
+import loggerFactory from '~/utils/logger';
+
+import { renameDuplicateRootPages } from './rename-duplicate-root-pages';
+
+const logger = loggerFactory('growi:service:NormalizeData');
+
+export const normalizeData = async(): Promise<void> => {
+  await renameDuplicateRootPages();
+
+  logger.info('normalizeData has been executed');
+  return;
+};

+ 31 - 0
apps/app/src/server/service/normalize-data/rename-duplicate-root-pages.ts

@@ -0,0 +1,31 @@
+// see: https://github.com/weseek/growi/issues/8337
+
+import { type IPageHasId } from '@growi/core';
+import mongoose from 'mongoose';
+
+import { type PageModel } from '~/server/models/page';
+
+export const renameDuplicateRootPages = async(): Promise<void> => {
+  const Page = mongoose.model<IPageHasId, PageModel>('Page');
+  const rootPages = await Page.find({ path: '/' }).sort({ createdAt: 1 });
+
+  if (rootPages.length <= 1) {
+    return;
+  }
+
+  const duplicatedRootPages = rootPages.slice(1);
+  const requests = duplicatedRootPages.map((page) => {
+    return {
+      updateOne: {
+        filter: { _id: page._id },
+        update: {
+          $set: {
+            parent: rootPages[0],
+            path: `/obsolete-root-page-${page._id.toString()}`,
+          },
+        },
+      },
+    };
+  });
+  await Page.bulkWrite(requests);
+};

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

@@ -333,6 +333,7 @@ class PageService implements IPageService {
         meta: {
           isV5Compatible: isTopPage(page.path) || page.parent != null,
           isEmpty: page.isEmpty,
+          isMovable: false,
           isDeletable: false,
           isAbleToDeleteCompletely: false,
           isRevertible: false,
@@ -2408,12 +2409,14 @@ class PageService implements IPageService {
   }
 
   constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): IPageInfo | IPageInfoForEntity {
+    const isMovable = isGuestUser ? false : isMovablePage(page.path);
     const isDeletable = !(isGuestUser || isTopPage(page.path) || isUsersTopPage(page.path));
 
     if (page.isEmpty) {
       return {
         isV5Compatible: true,
         isEmpty: true,
+        isMovable,
         isDeletable: false,
         isAbleToDeleteCompletely: false,
         isRevertible: false,
@@ -2430,6 +2433,7 @@ class PageService implements IPageService {
       likerIds: this.extractStringIds(likers),
       seenUserIds: this.extractStringIds(seenUsers),
       sumOfSeenUsers: page.seenUsers.length,
+      isMovable,
       isDeletable,
       isAbleToDeleteCompletely: false,
       isRevertible: isTrashPage(page.path),

+ 22 - 0
apps/app/src/server/service/socket-io.js

@@ -1,8 +1,12 @@
+import { GlobalSocketEventName } from '@growi/core/dist/interfaces';
 import { Server } from 'socket.io';
 
 import loggerFactory from '~/utils/logger';
+
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 
+import YjsConnectionManager from './yjs-connection-manager';
+
 const expressSession = require('express-session');
 const passport = require('passport');
 
@@ -33,6 +37,9 @@ class SocketIoService {
     });
     this.io.attach(server);
 
+    // create the YjsConnectionManager instance
+    this.yjsConnectionManager = new YjsConnectionManager(this.io);
+
     // create namespace for admin
     this.adminNamespace = this.io.of('/admin');
 
@@ -47,6 +54,7 @@ class SocketIoService {
 
     await this.setupLoginedUserRoomsJoinOnConnection();
     await this.setupDefaultSocketJoinRoomsEventHandler();
+    await this.setupYjsConnection();
   }
 
   getDefaultSocket() {
@@ -151,6 +159,20 @@ class SocketIoService {
     });
   }
 
+  setupYjsConnection() {
+    this.io.on('connection', (socket) => {
+      socket.on(GlobalSocketEventName.YDocSync, async({ pageId, initialValue }) => {
+        try {
+          await this.yjsConnectionManager.handleYDocSync(pageId, initialValue);
+        }
+        catch (error) {
+          logger.warn(error.message);
+          socket.emit(GlobalSocketEventName.YDocSyncError, 'An error occurred during YDoc synchronization.');
+        }
+      });
+    });
+  }
+
   async checkConnectionLimitsForAdmin(socket, next) {
     const namespaceName = socket.nsp.name;
 

+ 73 - 0
apps/app/src/server/service/yjs-connection-manager.ts

@@ -0,0 +1,73 @@
+import type { Server } from 'socket.io';
+import { MongodbPersistence } from 'y-mongodb-provider';
+import { YSocketIO } from 'y-socket.io/dist/server';
+import * as Y from 'yjs';
+
+import { getMongoUri } from '../util/mongoose-utils';
+
+export const MONGODB_PERSISTENCE_COLLECTION_NAME = 'yjs-writings';
+export const MONGODB_PERSISTENCE_FLUSH_SIZE = 100;
+
+class YjsConnectionManager {
+
+  private ysocketio: YSocketIO;
+
+  private mdb: MongodbPersistence;
+
+  constructor(io: Server) {
+    this.ysocketio = new YSocketIO(io);
+    this.ysocketio.initialize();
+
+    this.mdb = new MongodbPersistence(getMongoUri(), {
+      collectionName: MONGODB_PERSISTENCE_COLLECTION_NAME,
+      flushSize: MONGODB_PERSISTENCE_FLUSH_SIZE,
+    });
+
+    this.getCurrentYdoc = this.getCurrentYdoc.bind(this);
+  }
+
+  public async handleYDocSync(pageId: string, initialValue: string): Promise<void> {
+    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();
+
+    if (persistedCodeMirrorText === '' && currentCodeMirrorText === '') {
+      currentYdoc.getText('codemirror').insert(0, initialValue);
+    }
+
+    const diff = Y.encodeStateAsUpdate(currentYdoc, persistedStateVector);
+
+    if (diff.reduce((prev, curr) => prev + curr, 0) > 0) {
+      this.mdb.storeUpdate(pageId, diff);
+    }
+
+    Y.applyUpdate(currentYdoc, Y.encodeStateAsUpdate(persistedYdoc));
+
+    currentYdoc.on('update', async(update) => {
+      await this.mdb.storeUpdate(pageId, update);
+    });
+
+    currentYdoc.on('destroy', async() => {
+      await this.mdb.flushDocument(pageId);
+    });
+
+    persistedYdoc.destroy();
+  }
+
+  private getCurrentYdoc(pageId: string): Y.Doc {
+    const currentYdoc = this.ysocketio.documents.get(`yjs/${pageId}`);
+    if (currentYdoc == null) {
+      throw new Error(`currentYdoc for pageId ${pageId} is undefined.`);
+    }
+    return currentYdoc;
+  }
+
+}
+
+export default YjsConnectionManager;

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

@@ -124,7 +124,6 @@ export const useAuditLogEnabled = (initialData?: boolean): SWRResponse<boolean,
   return useContextSWR<boolean, Error>('auditLogEnabled', initialData, { fallbackData: false });
 };
 
-// TODO: initialize in [[..path]].page.tsx?
 export const useActivityExpirationSeconds = (initialData?: number) : SWRResponse<number, Error> => {
   return useContextSWR<number, Error>('activityExpirationSeconds', initialData);
 };

+ 2 - 5
apps/app/src/stores/ui.tsx

@@ -9,8 +9,9 @@ import { Breakpoint } from '@growi/ui/dist/interfaces';
 import { addBreakpointListener, cleanupBreakpointListener } from '@growi/ui/dist/utils';
 import type { HtmlElementNode } from 'rehype-toc';
 import type SimpleBar from 'simplebar-react';
+import type { MutatorOptions } from 'swr';
 import {
-  useSWRConfig, type SWRResponse, type Key, KeyedMutator, MutatorOptions,
+  useSWRConfig, type SWRResponse, type Key,
 } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
@@ -351,10 +352,6 @@ export const useSelectedGrant = (initialData?: Nullable<IPageGrantData>): SWRRes
   return useStaticSWR<Nullable<IPageGrantData>, Error>('selectedGrant', initialData, { fallbackData: { grant: PageGrant.GRANT_PUBLIC } });
 };
 
-export const useGlobalSearchFormRef = (initialData?: RefObject<IFocusable>): SWRResponse<RefObject<IFocusable>, Error> => {
-  return useStaticSWR('globalSearchTypeahead', initialData);
-};
-
 type PageTreeDescCountMapUtils = {
   update(newData?: UpdateDescCountData): Promise<UpdateDescCountData | undefined>
   getDescCount(pageId?: string): number | null | undefined

+ 1 - 1
apps/app/src/stores/use-static-swr.ts

@@ -1,6 +1,6 @@
 import { useSWRStatic } from '@growi/core/dist/swr';
 
 /**
- * @deprecated Import { uswSWRStatic } from '@growi/core/dist/swr' instead.
+ * @deprecated Import { useSWRStatic } from '@growi/core/dist/swr' instead.
  */
 export const useStaticSWR = useSWRStatic;

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

@@ -1,5 +1,6 @@
 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';
 
@@ -9,9 +10,6 @@ import { useStaticSWR } from './use-static-swr';
 
 const logger = loggerFactory('growi:stores:ui');
 
-export const GLOBAL_SOCKET_NS = '/';
-export const GLOBAL_SOCKET_KEY = 'globalSocket';
-
 export const GLOBAL_ADMIN_SOCKET_NS = '/admin';
 export const GLOBAL_ADMIN_SOCKET_KEY = 'globalAdminSocket';
 
@@ -40,10 +38,6 @@ export const useSetupGlobalSocket = (): void => {
   }, [mutate]);
 };
 
-export const useGlobalSocket = (): SWRResponse<Socket, Error> => {
-  return useStaticSWR(GLOBAL_SOCKET_KEY);
-};
-
 // comment out for porduction build error: https://github.com/weseek/growi/pull/7131
 /*
  * Global Admin Socket

+ 0 - 46
apps/app/src/styles/_mixins.scss

@@ -22,52 +22,6 @@
   }
 }
 
-@mixin expand-editor($editor-margin-top) {
-  $header-plus-footer: $editor-margin-top + $grw-editor-navbar-bottom-height;
-
-  $editor-margin: $header-plus-footer //
-    + 25px //   add .btn-open-dropzone height
-    + 30px; //  add .navbar-editor height
-
-  .editor-root {
-    width: 100%;
-    height: calc(100vh - #{$header-plus-footer});
-    min-height: calc(100vh - #{$header-plus-footer}); // for IE11
-    margin-top: 0px !important;
-
-    // left(editor)
-    .page-editor-editor-container {
-      height: calc(100vh - #{$header-plus-footer});
-      min-height: calc(100vh - #{$header-plus-footer}); // for IE11
-
-      .react-codemirror2,
-      .CodeMirror,
-      .CodeMirror-scroll,
-      .textarea-editor {
-        height: calc(100vh - #{$editor-margin});
-      }
-    }
-
-    // right(preview)
-    .page-editor-preview-container,
-    .page-editor-preview-body {
-      height: calc(100vh - #{$header-plus-footer});
-      min-height: calc(100vh - #{$header-plus-footer}); // for IE11
-    }
-  }
-
-  .editor-root#page-editor-with-hackmd {
-    &,
-    .hackmd-preinit,
-    .hackmd-error,
-    #iframe-hackmd-container > iframe {
-      width: 100%;
-      height: calc(100vh - #{$header-plus-footer});
-      min-height: calc(100vh - #{$header-plus-footer}); // for IE11
-    }
-  }
-}
-
 @mixin apply-navigation-transition() {
   transition-timing-function: cubic-bezier(0.25, 1, 0.5, 1);
   transition-duration: 300ms;

+ 0 - 31
apps/app/src/styles/_tag.scss

@@ -1,31 +0,0 @@
-@use '@growi/core/scss/bootstrap/init' as bs;
-
-.tags-page {
-  .list-tag-count {
-    background: rgba(0, 0, 0, 0.08);
-  }
-}
-
-.grw-popular-tag-labels {
-  text-align: left;
-
-  .grw-tag-label {
-    font-size: 10px;
-    font-weight: normal;
-    border-radius: bs.$border-radius;
-  }
-}
-
-#edit-tag-modal {
-  .form-control {
-    height: auto;
-  }
-}
-
-.grw-recent-changes {
-  .grw-tag-label {
-    font-size: 10px;
-    font-weight: normal;
-    border-radius: bs.$border-radius;
-  }
-}

+ 0 - 1
apps/app/src/styles/_variables.scss

@@ -9,6 +9,5 @@ $grw-marker-green: #6f6;
 $grw-sidebar-nav-width: 48px;
 
 $grw-navbar-bottom-height: 62px;
-$grw-editor-navbar-bottom-height: 48px;
 
 $grw-scroll-margin-top-in-view: 130px;

+ 18 - 0
apps/app/src/styles/atoms/_tag.scss

@@ -0,0 +1,18 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+// == Colors
+@include bs.color-mode(light) {
+  .grw-tag.badge {
+    --bs-badge-color: var(--bs-gray-600);
+    background-color: var(--bs-gray-100);
+    border: 1px solid var(--bs-gray-300);
+  }
+}
+@include bs.color-mode(dark) {
+  .grw-tag.badge {
+    --bs-badge-color: var(--bs-gray-500);
+    background-color: var(--bs-gray-800);
+    border: 1px solid var(--bs-gray-600);
+  }
+}
+

+ 18 - 6
apps/app/src/styles/organisms/_wiki.scss

@@ -8,7 +8,7 @@
       margin-right: 0.4em;
       content: '';
       border-left: $width solid;
-      opacity: 0.2;
+      border-left-color: var(--bs-border-color);
     }
   }
 
@@ -41,12 +41,13 @@
   }
 
   h1 {
-    padding: 0.5em 0;
+    padding: 0.3em 0;
     margin-top: 2em;
     font-size: 1.9em;
     line-height: 1.1em;
-    // style
-    border-bottom: solid 1px transparent;
+    border-bottom-color: var(--bs-border-color);
+    border-bottom-style: solid;
+    border-bottom-width: 2px;
   }
 
   h2 {
@@ -54,8 +55,9 @@
     font-size: 1.6em;
     font-weight: bold;
     line-height: 1.225;
-    // style
-    border-bottom: solid 1px transparent;
+    border-bottom-color: var(--bs-border-color);
+    border-bottom-style: solid;
+    border-bottom-width: 1px;
   }
 
   h3 {
@@ -309,6 +311,16 @@
         var(--bs-link-opacity, 1)
       );
     }
+  }
+}
 
+@include bs.color-mode(light) {
+  .wiki {
+    --bs-border-color: var(--bs-gray-300);
+  }
+}
+@include bs.color-mode(dark) {
+  .wiki {
+    --bs-border-color: var(--bs-gray-700);
   }
 }

+ 1 - 1
apps/app/src/styles/style-app.scss

@@ -7,6 +7,7 @@
 @import 'atoms/spinners';
 @import 'atoms/custom_control';
 @import 'atoms/code';
+@import 'atoms/tag';
 
 // molecules
 @import 'molecules/toastr';
@@ -25,7 +26,6 @@
 @import 'mirror_mode';
 @import 'modal';
 @import 'share-link';
-@import 'tag';
 @import 'installer';
 
 

+ 55 - 50
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--comments.cy.ts

@@ -24,7 +24,7 @@ context('Comment', () => {
       // until
       return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
     });
-    cy.get('.CodeMirror').should('be.visible');
+    cy.get('.cm-content').should('be.visible');
 
     cy.getByTestid('page-editor').should('be.visible');
     cy.getByTestid('save-page-btn').click();
@@ -43,7 +43,7 @@ context('Comment', () => {
       return cy.get('.comment-write').then($elem => $elem.is(':visible'));
     });
 
-    cy.get('.CodeMirror').type(commetText);
+    cy.get('.cm-content').type(commetText);
     cy.getByTestid("comment-submit-button").eq(0).click();
 
     // Check update comment count
@@ -65,71 +65,76 @@ context('Comment', () => {
       return cy.get('.comment-write').then($elem => $elem.is(':visible'));
     });
 
-    cy.get('.CodeMirror').type(commetText);
+    cy.get('.cm-content').type(commetText);
     cy.getByTestid("comment-submit-button").eq(0).click();
 
+    // TODO : https://redmine.weseek.co.jp/issues/139431
     // Check update comment count
-    commentCount += 1
-    cy.getByTestid('page-comment-button').contains(commentCount);
-    cy.screenshot(`${ssPrefix}2-reply-comments`);
+    // commentCount += 1
+    // cy.getByTestid('page-comment-button').contains(commentCount);
+    // cy.screenshot(`${ssPrefix}2-reply-comments`);
   });
 
-  it('Successfully delete comments', () => {
+  // TODO:https://redmine.weseek.co.jp/issues/139467
+  // it('Successfully delete comments', () => {
 
-    cy.getByTestid('page-comment-button').click();
+  //   cy.getByTestid('page-comment-button').click();
 
-    cy.get('.page-comments').should('be.visible');
-    cy.getByTestid('comment-delete-button').eq(0).click({force: true});
-    cy.get('.modal-content').then($elem => $elem.is(':visible'));
-    cy.get('.modal-footer > button:nth-child(3)').click();
+  //   cy.get('.page-comments').should('be.visible');
+  //   cy.getByTestid('comment-delete-button').eq(0).click({force: true});
+  //   cy.get('.modal-content').then($elem => $elem.is(':visible'));
+  //   cy.get('.modal-footer > button:nth-child(3)').click();
 
-    // Check update comment count
-    commentCount -= 2
-    cy.getByTestid('page-comment-button').contains(commentCount);
-    cy.screenshot(`${ssPrefix}3-delete-comments`);
-  });
+  //   // Check update comment count
+  //   commentCount -= 2
+  //   cy.getByTestid('page-comment-button').contains(commentCount);
+  //   cy.screenshot(`${ssPrefix}3-delete-comments`);
+  // });
 
-  // Mention username in comment
-  it('Successfully mention username in comment', () => {
-    const username = '@adm';
 
-    cy.getByTestid('page-comment-button').click();
+  // TODO: https://redmine.weseek.co.jp/issues/139520
+  // // Mention username in comment
+  // it('Successfully mention username in comment', () => {
+  //   const username = '@adm';
 
-    // Open comment editor
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('open-comment-editor-button').click();
-      // wait until
-      return cy.get('.comment-write').then($elem => $elem.is(':visible'));
-    });
+  //   cy.getByTestid('page-comment-button').click();
 
-    cy.appendTextToEditorUntilContains(username);
+  //   // Open comment editor
+  //   cy.waitUntil(() => {
+  //     // do
+  //     cy.getByTestid('open-comment-editor-button').click();
+  //     // wait until
+  //     return cy.get('.comment-write').then($elem => $elem.is(':visible'));
+  //   });
 
-    cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}4-mention-username-found`) });
-    // Click on mentioned username
-    cy.get('.CodeMirror-hints > li').first().click();
-    cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}5-mention-username-mentioned`) });
-  });
+  //   cy.appendTextToEditorUntilContains(username);
 
-  it('Username not found when mention username in comment', () => {
-    const username = '@user';
+  //   cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}4-mention-username-found`) });
+  //   // Click on mentioned username
+  //   cy.get('.CodeMirror-hints > li').first().click();
+  //   cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}5-mention-username-mentioned`) });
+  // });
 
-    cy.getByTestid('page-comment-button').click();
+  // TODO: https://redmine.weseek.co.jp/issues/139520
+  // it('Username not found when mention username in comment', () => {
+  //   const username = '@user';
 
-    // Open comment editor
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('open-comment-editor-button').click();
-      // wait until
-      return cy.get('.comment-write').then($elem => $elem.is(':visible'));
-    });
+  //   cy.getByTestid('page-comment-button').click();
 
-    cy.appendTextToEditorUntilContains(username);
+  //   // Open comment editor
+  //   cy.waitUntil(() => {
+  //     // do
+  //     cy.getByTestid('open-comment-editor-button').click();
+  //     // wait until
+  //     return cy.get('.comment-write').then($elem => $elem.is(':visible'));
+  //   });
 
-    cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}6-mention-username-not-found`) });
-    // Click on username not found hint
-    cy.get('.CodeMirror-hints > li').first().click();
-    cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}7-mention-no-username-mentioned`) });
-  });
+  //   cy.appendTextToEditorUntilContains(username);
+
+  //   cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}6-mention-username-not-found`) });
+  //   // Click on username not found hint
+  //   cy.get('.CodeMirror-hints > li').first().click();
+  //   cy.get('#comments-container').within(() => { cy.screenshot(`${ssPrefix}7-mention-no-username-mentioned`) });
+  // });
 
 })

+ 10 - 10
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--sticky-features.cy.ts

@@ -19,7 +19,7 @@ context('Access to any page', () => {
       // Scroll the window 250px down is enough to trigger sticky effect
        cy.scrollTo(0, 250);
       // wait until
-      return cy.getByTestid('grw-subnav-switcher').then($elem => !$elem.hasClass('grw-subnav-switcher-hidden'));
+      return cy.get('.sticky-outer-wrapper').should('have.class', 'active');
     });
 
     cy.waitUntilSkeletonDisappear();
@@ -30,7 +30,7 @@ context('Access to any page', () => {
       // Scroll the window back to top
       cy.scrollTo(0, 0);
       // wait until
-      return cy.waitUntil(() => cy.getByTestid('grw-subnav-switcher').then($elem => $elem.hasClass('grw-subnav-switcher-hidden')));
+      return cy.get('.sticky-outer-wrapper').should('not.have.class', 'active');
     });
 
     cy.screenshot(`${ssPrefix}invisible-on-scroll-top`);
@@ -42,7 +42,7 @@ context('Access to any page', () => {
       // Scroll the window 250px down is enough to trigger sticky effect
       cy.scrollTo(0, 250);
       // wait until
-      return () => cy.getByTestid('grw-subnav-switcher').then($elem => !$elem.hasClass('grw-subnav-switcher-hidden'));
+      return cy.get('.sticky-outer-wrapper').should('have.class', 'active');
     });
 
     // Move to /Sandbox page
@@ -51,7 +51,7 @@ context('Access to any page', () => {
     cy.waitUntilSkeletonDisappear();
     cy.collapseSidebar(true);
 
-    cy.waitUntil(() => cy.getByTestid('grw-subnav-switcher').then($elem => $elem.hasClass('grw-subnav-switcher-hidden')));
+    return cy.get('.sticky-outer-wrapper').should('not.have.class', 'active');
     cy.screenshot(`${ssPrefix}not-visible-on-move-to-other-pages`);
   });
 
@@ -61,17 +61,17 @@ context('Access to any page', () => {
       // Scroll the window 250px down is enough to trigger sticky effect
       cy.scrollTo(0, 250);
       // wait until
-      return cy.getByTestid('grw-subnav-switcher').then($elem => !$elem.hasClass('grw-subnav-switcher-hidden'));
+      return cy.get('.sticky-outer-wrapper').should('have.class', 'active');
     });
     cy.waitUntil(() => {
-      cy.getByTestid('grw-subnav-switcher').within(() => {
+      cy.getByTestid('grw-contextual-sub-nav').within(() => {
         cy.getByTestid('editor-button').as('editorButton').should('be.visible');
         cy.get('@editorButton').click();
       });
       return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
     });
-    cy.get('.grw-editor-navbar-bottom').should('be.visible');
-    cy.get('.CodeMirror').should('be.visible');
+    cy.getByTestid('grw-editor-navbar-bottom').should('be.visible');
+    // cy.get('.CodeMirror').should('be.visible');
     cy.screenshot(`${ssPrefix}open-editor-when-sticky`);
   });
 
@@ -81,11 +81,11 @@ context('Access to any page', () => {
       // Scroll the window 500px down
       cy.scrollTo(0, 500);
       // wait until
-      return cy.getByTestid('grw-subnav-switcher').then($elem => !$elem.hasClass('grw-subnav-switcher-hidden'));
+      return cy.get('.sticky-outer-wrapper').should('have.class', 'active');
     });
     cy.waitUntilSkeletonDisappear();
     cy.viewport(600, 1024);
-    cy.getByTestid('grw-subnav-switcher').within(() => {
+    cy.getByTestid('grw-contextual-sub-nav').within(() => {
       cy.get('#grw-page-editor-mode-manager').should('be.visible');
     })
     cy.screenshot(`${ssPrefix}sticky-on-small-window`);

+ 3 - 3
apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--access-to-page.cy.ts

@@ -22,7 +22,7 @@ context('Access to page by guest', () => {
     // https://stackoverflow.com/questions/5041494/selecting-and-manipulating-css-pseudo-elements-such-as-before-and-after-usin/21709814#21709814
     cy.get('#headers').invoke('removeClass', 'blink');
 
-    cy.waitUntilSkeletonDisappear();
+    // cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}-sandbox-headers`);
   });
 
@@ -36,7 +36,7 @@ context('Access to page by guest', () => {
 
     cy.get('.math').should('be.visible');
 
-    cy.waitUntilSkeletonDisappear();
+    // cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}-sandbox-math`);
   });
 
@@ -44,7 +44,7 @@ context('Access to page by guest', () => {
     cy.visit('/Sandbox#edit');
     cy.collapseSidebar(true);
 
-    cy.waitUntilSkeletonDisappear();
+    // cy.waitUntilSkeletonDisappear();
     cy.screenshot(`${ssPrefix}-sandbox-with-edit-hash`);
   })
 

+ 2 - 2
apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--sticky-for-guest.cy.ts

@@ -12,7 +12,7 @@ context('Access sticky sub navigation switcher for guest', () => {
       // Scroll page down 250px
       cy.scrollTo(0, 250);
       // wait until
-      return cy.getByTestid('grw-subnav-switcher').then($elem => !$elem.hasClass('grw-subnav-switcher-hidden'));
+      return cy.get('.sticky-outer-wrapper').should('have.class', 'active');
     });
     cy.screenshot(`${ssPrefix}subnav-switcher-is-sticky-on-scroll-down`);
 
@@ -22,7 +22,7 @@ context('Access sticky sub navigation switcher for guest', () => {
       // Scroll page to top
       cy.scrollTo(0, 0);
       // wait until
-      return cy.getByTestid('grw-subnav-switcher').then($elem => $elem.hasClass('grw-subnav-switcher-hidden'));
+      return cy.get('.sticky-outer-wrapper').should('not.have.class', 'active');
     });
     cy.screenshot(`${ssPrefix}subnav-switcher-is-not-sticky-on-scroll-top`);
   });

+ 16 - 0
apps/app/vitest.config.components.ts

@@ -0,0 +1,16 @@
+import react from '@vitejs/plugin-react';
+import tsconfigPaths from 'vite-tsconfig-paths';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  plugins: [
+    react(), tsconfigPaths(),
+  ],
+  test: {
+    globals: true,
+    environment: 'happy-dom',
+    include: [
+      '**/*.spec.{tsx,jsx}',
+    ],
+  },
+});

+ 1 - 1
apps/app/vitest.config.ts

@@ -8,7 +8,7 @@ export default defineConfig({
   test: {
     environment: 'node',
     exclude: [
-      '**/test/**',
+      '**/test/**', '**/*.spec.{tsx,jsx}',
     ],
     clearMocks: true,
     globals: true,

+ 1 - 1
package.json

@@ -89,7 +89,7 @@
     "ts-node-dev": "^2.0.0",
     "tsconfig-paths": "^3.9.0",
     "typescript": "~5.0.0",
-    "vite": "^4.5.1",
+    "vite": "^4.5.2",
     "vite-plugin-dts": "^2.3.0",
     "vite-tsconfig-paths": "^4.2.0",
     "vitest": "^0.34.6",

+ 1 - 1
packages/core/scss/bootstrap/apply.scss

@@ -36,7 +36,7 @@
 @import 'bootstrap/scss/placeholders';
 
 // Helpers
-@import 'bootstrap/scss/helpers';
+@import './override/helpers';
 
 // Utilities
 @import 'bootstrap/scss/utilities/api';

+ 59 - 0
packages/core/scss/bootstrap/mixins/_button-outline-neutral-variant.scss

@@ -0,0 +1,59 @@
+@mixin button-outline-neutral-variant-light(
+  $color,
+  $background: mix(#fff, $color, 100%),
+  $border: mix(#fff, $color, 70%),
+  $hover-background: mix(#fff, $color, 95%),
+  $hover-border: $border,
+  $hover-color: $color,
+  $active-background: mix(#fff, $color, 85%),
+  $active-border: $border,
+  $active-color: $color,
+  $disabled-background: $background,
+  $disabled-border: $border,
+  $disabled-color: $color
+) {
+
+  --#{$prefix}btn-color: #{$color};
+  --#{$prefix}btn-bg: #{$background};
+  --#{$prefix}btn-border-color: #{$border};
+  --#{$prefix}btn-hover-color: #{$hover-color};
+  --#{$prefix}btn-hover-bg: #{$hover-background};
+  --#{$prefix}btn-hover-border-color: #{$hover-border};
+  --#{$prefix}btn-active-color: #{$active-color};
+  --#{$prefix}btn-active-bg: #{$active-background};
+  --#{$prefix}btn-active-border-color: #{$active-border};
+  --#{$prefix}btn-active-shadow: #{$btn-active-box-shadow};
+  --#{$prefix}btn-disabled-color: #{$disabled-color};
+  --#{$prefix}btn-disabled-bg: #{$disabled-background};
+  --#{$prefix}btn-disabled-border-color: #{$disabled-border};
+}
+
+@mixin button-outline-neutral-variant-dark(
+  $color,
+  $background: mix($gray-900, $color, 100%),
+  $border: mix($gray-900, $color, 70%),
+  $hover-background: mix($gray-900, $color, 95%),
+  $hover-border: $border,
+  $hover-color: $color,
+  $active-background: mix($gray-900, $color, 85%),
+  $active-border: $border,
+  $active-color: $color,
+  $disabled-background: $background,
+  $disabled-border: $border,
+  $disabled-color: $color
+) {
+
+  --#{$prefix}btn-color: #{$color};
+  --#{$prefix}btn-bg: #{$background};
+  --#{$prefix}btn-border-color: #{$border};
+  --#{$prefix}btn-hover-color: #{$hover-color};
+  --#{$prefix}btn-hover-bg: #{$hover-background};
+  --#{$prefix}btn-hover-border-color: #{$hover-border};
+  --#{$prefix}btn-active-color: #{$active-color};
+  --#{$prefix}btn-active-bg: #{$active-background};
+  --#{$prefix}btn-active-border-color: #{$active-border};
+  --#{$prefix}btn-active-shadow: #{$btn-active-box-shadow};
+  --#{$prefix}btn-disabled-color: #{$disabled-color};
+  --#{$prefix}btn-disabled-bg: #{$disabled-background};
+  --#{$prefix}btn-disabled-border-color: #{$disabled-border};
+}

+ 14 - 0
packages/core/scss/bootstrap/override/_buttons.scss

@@ -1,4 +1,5 @@
 @import '../mixins/button-outline-variant';
+@import '../mixins/button-outline-neutral-variant';
 
 :root[data-bs-theme='light'] {
   @each $color, $value in $theme-colors {
@@ -19,3 +20,16 @@
     }
   }
 }
+
+// == .btn-outline-neutral-secondary
+:root[data-bs-theme='light'] {
+  .btn-outline-neutral-secondary {
+    @include button-outline-neutral-variant-light($secondary);
+  }
+}
+
+:root[data-bs-theme='dark'] {
+  .btn-outline-neutral-secondary {
+    @include button-outline-neutral-variant-dark($secondary);
+  }
+}

+ 12 - 0
packages/core/scss/bootstrap/override/_helpers.scss

@@ -0,0 +1,12 @@
+@import 'bootstrap/scss/helpers/clearfix';
+@import './helpers/color-bg';
+@import 'bootstrap/scss/helpers/colored-links';
+@import 'bootstrap/scss/helpers/focus-ring';
+@import 'bootstrap/scss/helpers/icon-link';
+@import 'bootstrap/scss/helpers/ratio';
+@import 'bootstrap/scss/helpers/position';
+@import 'bootstrap/scss/helpers/stacks';
+@import 'bootstrap/scss/helpers/visually-hidden';
+@import 'bootstrap/scss/helpers/stretched-link';
+@import 'bootstrap/scss/helpers/text-truncation';
+@import 'bootstrap/scss/helpers/vr';

+ 8 - 0
packages/core/scss/bootstrap/override/helpers/_color-bg.scss

@@ -0,0 +1,8 @@
+// All-caps `RGBA()` function used because of this Sass bug: https://github.com/sass/node-sass/issues/2251
+@each $color, $value in $theme-colors {
+  .text-bg-#{$color} {
+    color: var(--#{$prefix}#{$color}) if($enable-important-utilities, !important, null);
+    background-color: var(--#{$prefix}#{$color}-bg-subtle) if($enable-important-utilities, !important, null);
+    border: 1px solid var(--#{$prefix}#{$color}-border-subtle) if($enable-important-utilities, !important, null);
+  }
+}

+ 9 - 9
packages/core/scss/bootstrap/theming/_variables.scss

@@ -5,15 +5,15 @@
 
 // Color system
 
-$gray-100: #f8f9fa !default;
-$gray-200: #e9ecef !default;
-$gray-300: #dee2e6 !default;
-$gray-400: #ced4da !default;
-$gray-500: #adb5bd !default;
-$gray-600: #6c757d !default;
-$gray-700: #495057 !default;
-$gray-800: #343a40 !default;
-$gray-900: #212529 !default;
+$gray-100: #faf9f8 !default;
+$gray-200: #efeeed !default;
+$gray-300: #e6e5e3 !default;
+$gray-400: #d8d7d5 !default;
+$gray-500: #b2b0ae !default;
+$gray-600: #767371 !default;
+$gray-700: #4d4a48 !default;
+$gray-800: #403c39 !default;
+$gray-900: #26231e !default;
 
 // The contrast ratio to reach against white, to determine if color changes from "light" to "dark". Acceptable values for WCAG 2.0 are 3, 4.5 and 7.
 // See https://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast

+ 1 - 1
packages/core/scss/bootstrap/utilities.scss

@@ -1,4 +1,4 @@
 @import 'init';
 
-@import 'bootstrap/scss/helpers';
+@import './override/helpers';
 @import 'bootstrap/scss/utilities/api';

+ 1 - 0
packages/core/src/interfaces/index.ts

@@ -13,3 +13,4 @@ export * from './subscription';
 export * from './tag';
 export * from './user';
 export * from './vite';
+export * from './websocket';

+ 1 - 0
packages/core/src/interfaces/page.ts

@@ -80,6 +80,7 @@ export type IPageHasId = IPage & HasObjectId;
 export type IPageInfo = {
   isV5Compatible: boolean,
   isEmpty: boolean,
+  isMovable: boolean,
   isDeletable: boolean,
   isAbleToDeleteCompletely: boolean,
   isRevertible: boolean,

+ 6 - 0
packages/core/src/interfaces/websocket.ts

@@ -0,0 +1,6 @@
+export const GlobalSocketEventName = {
+  // YDoc
+  YDocSync: 'ydoc:sync',
+  YDocSyncError: 'ydoc:sync:error',
+} as const;
+export type GlobalSocketEventName = typeof GlobalSocketEventName[keyof typeof GlobalSocketEventName];

+ 1 - 0
packages/core/src/swr/index.ts

@@ -1,2 +1,3 @@
 export * from './use-swr-static';
 export * from './with-utils';
+export * from './use-global-socket';

+ 11 - 0
packages/core/src/swr/use-global-socket.ts

@@ -0,0 +1,11 @@
+import type { Socket } from 'socket.io-client';
+import type { SWRResponse } from 'swr';
+
+import { useSWRStatic } from './use-swr-static';
+
+export const GLOBAL_SOCKET_NS = '/';
+export const GLOBAL_SOCKET_KEY = 'globalSocket';
+
+export const useGlobalSocket = (): SWRResponse<Socket, Error> => {
+  return useSWRStatic(GLOBAL_SOCKET_KEY);
+};

+ 2 - 2
packages/core/src/utils/page-path-utils/index.ts

@@ -100,7 +100,7 @@ export const isSharedPage = (path: string): boolean => {
 };
 
 const restrictedPatternsToCreate: Array<RegExp> = [
-  /\^|\$|\*|\+|#|%|\?/,
+  /\^|\$|\*|\+|#|<|>|%|\?/,
   /^\/-\/.*/,
   /^\/_r\/.*/,
   /^\/_apix?(\/.*)?/,
@@ -114,7 +114,7 @@ const restrictedPatternsToCreate: Array<RegExp> = [
   /\\/, // see: https://github.com/weseek/growi/issues/7241
   /^\/(_search|_private-legacy-pages)(\/.*|$)/,
   /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments|tags|share|attachment)(\/.*|$)/,
-  /^\/user\/[^/]+$/, // see: https://regex101.com/r/utVQct/1
+  /^\/user(?:\/[^/]+)?$/, // https://regex101.com/r/9Eh2S1/1
 ];
 export const isCreatablePage = (path: string): boolean => {
   return !restrictedPatternsToCreate.some(pattern => path.match(pattern));

+ 11 - 2
packages/editor/package.json

@@ -22,15 +22,21 @@
   },
   "devDependencies": {
     "@codemirror/lang-markdown": "^6.2.0",
-    "@codemirror/language-data": "^6.3.1",
     "@codemirror/language": "^6.8.0",
+    "@codemirror/language-data": "^6.3.1",
     "@codemirror/state": "^6.2.1",
     "@codemirror/view": "^6.15.3",
     "@popperjs/core": "^2.11.8",
     "@types/react": "^18.2.14",
     "@types/react-dom": "^18.2.6",
+    "@uiw/codemirror-theme-eclipse": "^4.21.21",
+    "@uiw/codemirror-theme-kimbie": "^4.21.21",
+    "@uiw/codemirror-themes": "^4.21.21",
     "@uiw/react-codemirror": "^4.21.8",
     "bootstrap": "^5.3.1",
+    "cm6-theme-basic-light": "^0.2.0",
+    "cm6-theme-material-dark": "^0.2.0",
+    "cm6-theme-nord": "^0.2.0",
     "codemirror": "^6.0.1",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
     "eslint-plugin-react-refresh": "^0.4.1",
@@ -39,6 +45,9 @@
     "react-toastify": "^9.1.3",
     "reactstrap": "^9.2.0",
     "swr": "^2.2.2",
-    "ts-deepmerge": "^6.2.0"
+    "ts-deepmerge": "^6.2.0",
+    "y-codemirror.next": "^0.3.2",
+    "y-socket.io": "^1.1.0",
+    "yjs": "^13.6.7"
   }
 }

+ 22 - 2
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -3,13 +3,14 @@ import {
 } from 'react';
 
 import { indentUnit } from '@codemirror/language';
+import { Prec } from '@codemirror/state';
 import { EditorView } from '@codemirror/view';
 import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 
 import { GlobalCodeMirrorEditorKey, AcceptedUploadFileType } from '../../consts';
-import { useFileDropzone, FileDropzoneOverlay } from '../../services';
+import { useFileDropzone, FileDropzoneOverlay, AllEditorTheme } from '../../services';
 import {
-  getStrFromBol, adjustPasteData,
+  adjustPasteData, getStrFromBol,
 } from '../../services/list-util/markdown-list-util';
 import { useCodeMirrorEditorIsolated } from '../../stores';
 
@@ -31,6 +32,7 @@ type Props = {
   onUpload?: (files: File[]) => void,
   onScroll?: () => void,
   indentSize?: number,
+  editorTheme?: string,
 }
 
 export const CodeMirrorEditor = (props: Props): JSX.Element => {
@@ -41,6 +43,7 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
     onUpload,
     onScroll,
     indentSize,
+    editorTheme,
   } = props;
 
   const containerRef = useRef(null);
@@ -136,6 +139,23 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
 
   }, [onScroll, codeMirrorEditor]);
 
+  useEffect(() => {
+    if (editorTheme == null) {
+      return;
+    }
+    if (AllEditorTheme[editorTheme] == null) {
+      return;
+    }
+
+    const extension = AllEditorTheme[editorTheme];
+
+    // React CodeMirror has default theme which is default prec
+    // and extension have to be higher prec here than default theme.
+    const cleanupFunction = codeMirrorEditor?.appendExtensions(Prec.high(extension));
+    return cleanupFunction;
+
+  }, [codeMirrorEditor, editorTheme]);
+
   const {
     getRootProps,
     isDragActive,

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff