Przeglądaj źródła

Merge branch 'dev/7.0.x' into fix/139423-fix-DropendToggle-of-PageCreateButton

WNomunomu 2 lat temu
rodzic
commit
dae20c9201
97 zmienionych plików z 1541 dodań i 850 usunięć
  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. 2 1
      apps/app/src/client/services/side-effects/page-updated.ts
  6. 37 0
      apps/app/src/components/Common/Dropdown/PageItemControl.spec.tsx
  7. 3 5
      apps/app/src/components/Common/Dropdown/PageItemControl.tsx
  8. 8 10
      apps/app/src/components/Hotkeys/Subscribers/FocusToGlobalSearch.jsx
  9. 1 3
      apps/app/src/components/ItemsTree/ItemsTree.module.scss
  10. 1 1
      apps/app/src/components/ItemsTree/ItemsTree.tsx
  11. 1 1
      apps/app/src/components/Layout/SearchResultLayout.tsx
  12. 0 15
      apps/app/src/components/Me/ColorModeSettings.module.scss
  13. 34 26
      apps/app/src/components/Me/ColorModeSettings.tsx
  14. 21 14
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  15. 68 37
      apps/app/src/components/Navbar/hooks.tsx
  16. 2 3
      apps/app/src/components/PageComment/CommentEditor.tsx
  17. 7 3
      apps/app/src/components/PageDeleteModal.tsx
  18. 0 2
      apps/app/src/components/PageEditor/EditorNavbarBottom.module.scss
  19. 4 4
      apps/app/src/components/PageEditor/EditorNavbarBottom.tsx
  20. 32 35
      apps/app/src/components/PageEditor/PageEditor.tsx
  21. 5 4
      apps/app/src/components/PageSelectModal/TreeItemForModal.tsx
  22. 2 2
      apps/app/src/components/PageSideContents/PageAccessoriesControl.tsx
  23. 1 1
      apps/app/src/components/PageTags/RenderTagLabels.tsx
  24. 1 1
      apps/app/src/components/PrivateLegacyPages.tsx
  25. 3 3
      apps/app/src/components/SavePageControls.tsx
  26. 2 2
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  27. 4 7
      apps/app/src/components/Sidebar/PageCreateButton/DropendToggle.tsx
  28. 2 6
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  29. 11 4
      apps/app/src/components/Sidebar/PageCreateButton/hooks.tsx
  30. 0 2
      apps/app/src/components/Sidebar/PageTree/PageTreeSubstance.tsx
  31. 19 3
      apps/app/src/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  32. 30 21
      apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.module.scss
  33. 73 37
      apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  34. 4 1
      apps/app/src/components/Sidebar/Sidebar.tsx
  35. 1 3
      apps/app/src/components/Sidebar/Tag.tsx
  36. 2 2
      apps/app/src/components/TagCloudBox.tsx
  37. 27 0
      apps/app/src/components/TagList.module.scss
  38. 11 6
      apps/app/src/components/TagList.tsx
  39. 43 33
      apps/app/src/components/TreeItem/SimpleItem.tsx
  40. 3 1
      apps/app/src/components/TreeItem/interfaces/index.ts
  41. 0 1
      apps/app/src/interfaces/websocket.ts
  42. 26 5
      apps/app/src/pages/[[...path]].page.tsx
  43. 6 1
      apps/app/src/pages/admin/audit-log.page.tsx
  44. 4 19
      apps/app/src/pages/share/[[...path]].page.tsx
  45. 18 0
      apps/app/src/pages/utils/commons.ts
  46. 3 0
      apps/app/src/server/crowi/index.js
  47. 1 0
      apps/app/src/server/models/obsolete-page.js
  48. 25 10
      apps/app/src/server/service/growi-bridge/index.ts
  49. 22 0
      apps/app/src/server/service/growi-bridge/unzip-stream-utils.ts
  50. 9 4
      apps/app/src/server/service/import.js
  51. 12 0
      apps/app/src/server/service/normalize-data/index.ts
  52. 31 0
      apps/app/src/server/service/normalize-data/rename-duplicate-root-pages.ts
  53. 4 0
      apps/app/src/server/service/page/index.ts
  54. 22 0
      apps/app/src/server/service/socket-io.js
  55. 73 0
      apps/app/src/server/service/yjs-connection-manager.ts
  56. 0 1
      apps/app/src/stores/context.tsx
  57. 2 5
      apps/app/src/stores/ui.tsx
  58. 1 1
      apps/app/src/stores/use-static-swr.ts
  59. 1 7
      apps/app/src/stores/websocket.tsx
  60. 0 46
      apps/app/src/styles/_mixins.scss
  61. 0 31
      apps/app/src/styles/_tag.scss
  62. 0 1
      apps/app/src/styles/_variables.scss
  63. 18 0
      apps/app/src/styles/atoms/_tag.scss
  64. 18 6
      apps/app/src/styles/organisms/_wiki.scss
  65. 1 1
      apps/app/src/styles/style-app.scss
  66. 55 50
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--comments.cy.ts
  67. 16 0
      apps/app/vitest.config.components.ts
  68. 1 1
      apps/app/vitest.config.ts
  69. 1 1
      package.json
  70. 1 1
      packages/core/scss/bootstrap/apply.scss
  71. 59 0
      packages/core/scss/bootstrap/mixins/_button-outline-neutral-variant.scss
  72. 14 0
      packages/core/scss/bootstrap/override/_buttons.scss
  73. 12 0
      packages/core/scss/bootstrap/override/_helpers.scss
  74. 8 0
      packages/core/scss/bootstrap/override/helpers/_color-bg.scss
  75. 9 9
      packages/core/scss/bootstrap/theming/_variables.scss
  76. 1 1
      packages/core/scss/bootstrap/utilities.scss
  77. 1 0
      packages/core/src/interfaces/index.ts
  78. 1 0
      packages/core/src/interfaces/page.ts
  79. 6 0
      packages/core/src/interfaces/websocket.ts
  80. 1 0
      packages/core/src/swr/index.ts
  81. 11 0
      packages/core/src/swr/use-global-socket.ts
  82. 2 2
      packages/core/src/utils/page-path-utils/index.ts
  83. 4 1
      packages/editor/package.json
  84. 1 1
      packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx
  85. 9 3
      packages/editor/src/components/CodeMirrorEditorMain.tsx
  86. 1 0
      packages/editor/src/consts/index.ts
  87. 15 0
      packages/editor/src/consts/ydoc-awareness-user-color.ts
  88. 13 2
      packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts
  89. 10 11
      packages/editor/src/services/editor-theme/original-dark.ts
  90. 7 6
      packages/editor/src/services/editor-theme/original-light.ts
  91. 8 8
      packages/editor/src/services/list-util/markdown-list-util.ts
  92. 1 0
      packages/editor/src/stores/index.ts
  93. 121 0
      packages/editor/src/stores/use-collaborative-editor-mode.ts
  94. 3 0
      packages/preset-themes/src/consts/preset-themes.ts
  95. 67 0
      packages/preset-themes/src/styles/classic.scss
  96. 1 0
      packages/preset-themes/vite.themes.config.ts
  97. 304 309
      yarn.lock

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

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

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

+ 2 - 3
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -14,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,
@@ -27,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';
 
@@ -83,7 +82,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   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 ?? '');

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

+ 4 - 4
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';
 
 
@@ -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 ? (

+ 32 - 35
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,
@@ -21,10 +22,10 @@ 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';
@@ -53,7 +54,6 @@ 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';
@@ -120,6 +120,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { mutate: mutateRemoteRevisionId } = useRemoteRevisionBody();
   const { mutate: mutateRemoteRevisionLastUpdatedAt } = useRemoteRevisionLastUpdatedAt();
   const { mutate: mutateRemoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdateUser();
+  const { data: user } = useCurrentUser();
 
   const { data: socket } = useGlobalSocket();
 
@@ -135,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
@@ -174,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);
@@ -407,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();
@@ -456,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 <></>;
@@ -501,9 +494,13 @@ 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>

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

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

@@ -1,5 +1,3 @@
-import type { ButtonHTMLAttributes, DetailedHTMLProps } from 'react';
-
 import { DropdownToggle } from 'reactstrap';
 
 import { Hexagon } from './Hexagon';
@@ -10,13 +8,12 @@ import styles from './DropendToggle.module.scss';
 const moduleClass = styles['btn-toggle'];
 
 
-type Props = DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>;
-
-export const DropendToggle = (props: Props): JSX.Element => {
-
+export const DropendToggle = (): JSX.Element => {
   return (
     <DropdownToggle
-      className={`position-absolute ${moduleClass} btn btn-primary z-1 ${props.className ?? ''}`}
+      color="primary"
+      className={`position-absolute ${moduleClass}`}
+      aria-expanded={false}
     >
       <Hexagon />
       <div className="hitarea position-absolute" />

+ 2 - 6
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,7 +17,6 @@ import { DropendMenu } from './DropendMenu';
 import { DropendToggle } from './DropendToggle';
 import { useOnNewButtonClicked, useOnTodaysButtonClicked } from './hooks';
 
-import { Dropdown } from 'reactstrap';
 
 const generateTodaysPath = (currentUser: IUserHasId, parentDirName: string) => {
   const now = format(new Date(), 'yyyy/MM/dd');
@@ -87,11 +87,7 @@ export const PageCreateButton = React.memo((): JSX.Element => {
           direction="end"
           className="position-absolute"
         >
-          <DropendToggle
-            className="dropdown-toggle dropdown-toggle-split"
-            data-bs-toggle="dropdown"
-            aria-expanded="false"
-          />
+          <DropendToggle />
           <DropendMenu
             onClickCreateNewPageButtonHandler={onClickNewButton}
             onClickCreateTodaysButtonHandler={onClickTodaysButton}

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

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

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

+ 4 - 1
packages/editor/package.json

@@ -45,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"
   }
 }

+ 1 - 1
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -10,7 +10,7 @@ import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 import { GlobalCodeMirrorEditorKey, AcceptedUploadFileType } from '../../consts';
 import { useFileDropzone, FileDropzoneOverlay, AllEditorTheme } from '../../services';
 import {
-  getStrFromBol, adjustPasteData,
+  adjustPasteData, getStrFromBol,
 } from '../../services/list-util/markdown-list-util';
 import { useCodeMirrorEditorIsolated } from '../../stores';
 

+ 9 - 3
packages/editor/src/components/CodeMirrorEditorMain.tsx

@@ -5,11 +5,10 @@ import { keymap, scrollPastEnd } from '@codemirror/view';
 
 import { GlobalCodeMirrorEditorKey, AcceptedUploadFileType } from '../consts';
 import { setDataLine } from '../services/extensions/setDataLine';
-import { useCodeMirrorEditorIsolated } from '../stores';
+import { useCodeMirrorEditorIsolated, useCollaborativeEditorMode } from '../stores';
 
 import { CodeMirrorEditor } from '.';
 
-
 const additionalExtensions: Extension[] = [
   [
     scrollPastEnd(),
@@ -24,15 +23,22 @@ type Props = {
   onScroll?: () => void,
   acceptedFileType?: AcceptedUploadFileType,
   indentSize?: number,
+  userName?: string,
+  pageId?: string,
+  initialValue?: string,
+  onOpenEditor?: (markdown: string) => void,
   editorTheme?: string,
 }
 
 export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
   const {
-    onSave, onChange, onUpload, onScroll, acceptedFileType, indentSize, editorTheme,
+    onSave, onChange, onUpload, onScroll, acceptedFileType, indentSize, userName, pageId, initialValue, onOpenEditor, editorTheme,
   } = props;
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
+
+  useCollaborativeEditorMode(userName, pageId, initialValue, onOpenEditor, codeMirrorEditor);
+
   const acceptedFileTypeNoOpt = acceptedFileType ?? AcceptedUploadFileType.NONE;
 
   // setup additional extensions

+ 1 - 0
packages/editor/src/consts/index.ts

@@ -1,2 +1,3 @@
 export * from './global-code-mirror-editor-key';
+export * from './ydoc-awareness-user-color';
 export * from './accepted-upload-file-type';

+ 15 - 0
packages/editor/src/consts/ydoc-awareness-user-color.ts

@@ -0,0 +1,15 @@
+// see: https://github.com/yjs/y-codemirror.next#example
+import * as random from 'lib0/random';
+
+export const usercolors = [
+  { color: '#30bced', light: '#30bced33' },
+  { color: '#6eeb83', light: '#6eeb8333' },
+  { color: '#ffbc42', light: '#ffbc4233' },
+  { color: '#ecd444', light: '#ecd44433' },
+  { color: '#ee6352', light: '#ee635233' },
+  { color: '#9ac2c9', light: '#9ac2c933' },
+  { color: '#8acb88', light: '#8acb8833' },
+  { color: '#1be7ff', light: '#1be7ff33' },
+];
+
+export const userColor = usercolors[random.uint32() % usercolors.length];

+ 13 - 2
packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts

@@ -1,14 +1,22 @@
 import { useMemo } from 'react';
 
 import { indentWithTab, defaultKeymap } from '@codemirror/commands';
-import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
+import {
+  markdown, markdownLanguage,
+} from '@codemirror/lang-markdown';
 import { syntaxHighlighting, HighlightStyle, defaultHighlightStyle } from '@codemirror/language';
 import { languages } from '@codemirror/language-data';
-import { EditorState, Prec, type Extension } from '@codemirror/state';
+import {
+  EditorState, Prec, type Extension,
+} from '@codemirror/state';
 import { keymap, EditorView } from '@codemirror/view';
 import { tags } from '@lezer/highlight';
 import { useCodeMirror, type UseCodeMirror } from '@uiw/react-codemirror';
 import deepmerge from 'ts-deepmerge';
+// see: https://github.com/yjs/y-codemirror.next#example
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore
+import { yUndoManagerKeymap } from 'y-codemirror.next';
 
 import { emojiAutocompletionSettings } from '../../extensions/emojiAutocompletionSettings';
 
@@ -59,6 +67,7 @@ const defaultExtensions: Extension[] = [
   syntaxHighlighting(markdownHighlighting),
   Prec.lowest(syntaxHighlighting(defaultHighlightStyle)),
   emojiAutocompletionSettings,
+  keymap.of(yUndoManagerKeymap),
 ];
 
 
@@ -81,6 +90,8 @@ export const useCodeMirrorEditor = (props?: UseCodeMirror): UseCodeMirrorEditor
         basicSetup: {
           defaultKeymap: false,
           dropCursor: false,
+          // Disabled react-codemirror history for Y.UndoManager
+          history: false,
         },
         // ------- End -------
       },

+ 10 - 11
packages/editor/src/services/editor-theme/original-dark.ts

@@ -6,25 +6,24 @@ import { createTheme } from '@uiw/codemirror-themes';
 export const originalDark = createTheme({
   theme: 'dark',
   settings: {
-    background: '#303841',
-    foreground: '#FFFFFF',
-    caret: '#FBAC52',
+    background: '#323132',
+    foreground: '#EFEEED',
     selection: '#4C5964',
     selectionMatch: '#3A546E',
-    gutterBackground: '#303841',
-    gutterForeground: '#FFFFFF70',
-    lineHighlight: '#00000059',
+    gutterBackground: '#393939',
+    gutterForeground: '#6E6D6C',
+    lineHighlight: '#00000030',
   },
   styles: [
     { tag: [t.meta, t.comment], color: '#A2A9B5' },
-    { tag: [t.attributeName, t.keyword], color: '#B78FBA' },
+    { tag: [t.attributeName, t.keyword, t.operator], color: '#9B7F94' },
     { tag: t.function(t.variableName), color: '#5AB0B0' },
-    { tag: [t.string, t.regexp, t.attributeValue], color: '#99C592' },
-    { tag: t.operator, color: '#f47954' },
+    { tag: [t.string, t.attributeValue], color: '#7D9B7B' },
     // { tag: t.moduleKeyword, color: 'red' },
-    { tag: [t.tagName, t.modifier], color: '#E35F63' },
+    { tag: [t.tagName, t.modifier], color: '#BA6666' },
+    { tag: [t.url, t.escape, t.regexp, t.link], color: '#8FA7C7' },
     { tag: [t.number, t.definition(t.tagName), t.className, t.definition(t.variableName)], color: '#fbac52' },
-    { tag: [t.atom, t.bool, t.special(t.variableName)], color: '#E35F63' },
+    { tag: [t.atom, t.bool, t.special(t.variableName)], color: '#BA6666' },
     { tag: t.variableName, color: '#539ac4' },
     { tag: [t.propertyName, t.typeName], color: '#629ccd' },
     { tag: t.propertyName, color: '#36b7b5' },

+ 7 - 6
packages/editor/src/services/editor-theme/original-light.ts

@@ -12,24 +12,25 @@ export const originalLight: Extension = createTheme({
     foreground: '#24292e',
     selection: '#BBDFFF',
     selectionMatch: '#BBDFFF',
-    gutterBackground: '#fff',
-    gutterForeground: '#6e7781',
+    gutterBackground: '#FAF9F8',
+    gutterForeground: '#BCBBBA',
   },
   styles: [
-    { tag: [t.standard(t.tagName), t.tagName], color: '#116329' },
+    { tag: [t.standard(t.tagName), t.tagName], color: '#377148' },
     { tag: [t.comment, t.bracket], color: '#6a737d' },
     { tag: [t.className, t.propertyName], color: '#6f42c1' },
-    { tag: [t.variableName, t.attributeName, t.number, t.operator], color: '#005cc5' },
     { tag: [t.keyword, t.typeName, t.typeOperator, t.typeName], color: '#d73a49' },
-    { tag: [t.string, t.meta, t.regexp], color: '#032f62' },
     { tag: [t.name, t.quote], color: '#22863a' },
-    { tag: [t.heading, t.strong], color: '#24292e', fontWeight: 'bold' },
+    { tag: [t.heading], color: '#24292e', fontWeight: 'bold' },
     { tag: [t.emphasis], color: '#24292e', fontStyle: 'italic' },
     { tag: [t.deleted], color: '#b31d28', backgroundColor: 'ffeef0' },
+    { tag: [t.string, t.meta, t.regexp], color: '#032F62' },
     { tag: [t.atom, t.bool, t.special(t.variableName)], color: '#e36209' },
     { tag: [t.url, t.escape, t.regexp, t.link], color: '#032f62' },
     { tag: t.link, textDecoration: 'underline' },
     { tag: t.strikethrough, textDecoration: 'line-through' },
+    { tag: [t.variableName, t.attributeName, t.number, t.operator, t.character, t.brace, t.processingInstruction, t.inserted], color: '#516883' },
+    { tag: [t.strong], color: '#744763' },
     { tag: t.invalid, color: '#cb2431' },
   ],
 });

+ 8 - 8
packages/editor/src/services/list-util/markdown-list-util.ts

@@ -1,4 +1,4 @@
-import type { EditorView } from '@codemirror/view';
+import { EditorView } from '@codemirror/view';
 
 // https://regex101.com/r/7BN2fR/5
 const indentAndMarkRE = /^(\s*)(>[> ]*|[*+-] \[[x ]\]\s|[*+-]\s|(\d+)([.)]))(\s*)/;
@@ -14,19 +14,19 @@ export const getStrFromBol = (editor: EditorView): string => {
   return editor.state.sliceDoc(getBol(editor), curPos);
 };
 
-export const adjustPasteData = (indentAndMark: string, text: string): string => {
+export const adjustPasteData = (strFromBol: string, text: string): string => {
 
-  let adjusted;
+  let adjusted = text;
 
   if (text.match(indentAndMarkRE)) {
-    const matchResult = indentAndMark.match(indentAndMarkRE);
+    const matchResult = strFromBol.match(indentAndMarkRE);
     const indent = matchResult ? matchResult[1] : '';
 
     const lines = text.match(/[^\r\n]+/g);
 
     const replacedLines = lines?.map((line, index) => {
 
-      if (index === 0 && indentAndMark.match(indentAndMarkRE)) {
+      if (index === 0 && strFromBol.match(indentAndMarkRE)) {
         return line.replace(indentAndMarkRE, '');
       }
 
@@ -36,10 +36,10 @@ export const adjustPasteData = (indentAndMark: string, text: string): string =>
     adjusted = replacedLines ? replacedLines.join('\n') : '';
   }
 
-  else {
-    const replacedText = text.replace(/(\r\n|\r|\n)/g, `$1${indentAndMark}`);
+  else if (strFromBol.match(indentAndMarkRE)) {
+    const replacedText = text.replace(/(\r\n|\r|\n)/g, `$1${strFromBol}`);
 
-    adjusted = indentAndMark + replacedText;
+    adjusted = replacedText;
   }
 
   return adjusted;

+ 1 - 0
packages/editor/src/stores/index.ts

@@ -1,2 +1,3 @@
 export * from './codemirror-editor';
 export * from './use-resolved-theme';
+export * from './use-collaborative-editor-mode';

+ 121 - 0
packages/editor/src/stores/use-collaborative-editor-mode.ts

@@ -0,0 +1,121 @@
+import { useEffect, useState } from 'react';
+
+import { GlobalSocketEventName } from '@growi/core/dist/interfaces';
+import { useGlobalSocket, GLOBAL_SOCKET_NS } from '@growi/core/dist/swr';
+// see: https://github.com/yjs/y-codemirror.next#example
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore
+import { yCollab } from 'y-codemirror.next';
+import { SocketIOProvider } from 'y-socket.io';
+import * as Y from 'yjs';
+
+import { userColor } from '../consts';
+import { UseCodeMirrorEditor } from '../services';
+
+export const useCollaborativeEditorMode = (
+    userName?: string,
+    pageId?: string,
+    initialValue?: string,
+    onOpenEditor?: (markdown: string) => void,
+    codeMirrorEditor?: UseCodeMirrorEditor,
+): void => {
+  const [ydoc, setYdoc] = useState<Y.Doc | null>(null);
+  const [provider, setProvider] = useState<SocketIOProvider | null>(null);
+  const [isInit, setIsInit] = useState(false);
+  const [cPageId, setCPageId] = useState(pageId);
+
+  const { data: socket } = useGlobalSocket();
+
+  const cleanupYDocAndProvider = () => {
+    if (cPageId === pageId) {
+      return;
+    }
+
+    ydoc?.destroy();
+    setYdoc(null);
+
+    // NOTICE: Destorying the provider leaves awareness in the other user's connection,
+    // so only awareness is destoryed here
+    provider?.awareness.destroy();
+
+    // TODO: catch ydoc:sync:error GlobalSocketEventName.YDocSyncError
+    socket?.off(GlobalSocketEventName.YDocSync);
+
+    setIsInit(false);
+    setCPageId(pageId);
+  };
+
+  const setupYDoc = () => {
+    if (ydoc != null) {
+      return;
+    }
+
+    // NOTICE: Old provider destory at the time of ydoc setup,
+    // because the awareness destroying is not sync to other clients
+    provider?.destroy();
+    setProvider(null);
+
+    const _ydoc = new Y.Doc();
+    setYdoc(_ydoc);
+  };
+
+  const setupProvider = () => {
+    if (provider != null || ydoc == null || socket == null) {
+      return;
+    }
+
+    const socketIOProvider = new SocketIOProvider(
+      GLOBAL_SOCKET_NS,
+      `yjs/${pageId}`,
+      ydoc,
+      { autoConnect: true },
+    );
+
+    socketIOProvider.awareness.setLocalStateField('user', {
+      name: userName ? `${userName}` : `Guest User ${Math.floor(Math.random() * 100)}`,
+      color: userColor.color,
+      colorLight: userColor.light,
+    });
+
+    socketIOProvider.on('sync', (isSync: boolean) => {
+      if (isSync) {
+        socket.emit(GlobalSocketEventName.YDocSync, { pageId, initialValue });
+      }
+    });
+
+    setProvider(socketIOProvider);
+  };
+
+  const setupYDocExtensions = () => {
+    if (ydoc == null || provider == null) {
+      return;
+    }
+
+    const ytext = ydoc.getText('codemirror');
+    const undoManager = new Y.UndoManager(ytext);
+
+    const cleanup = codeMirrorEditor?.appendExtensions?.([
+      yCollab(ytext, provider.awareness, { undoManager }),
+    ]);
+
+    return cleanup;
+  };
+
+  const initializeEditor = () => {
+    if (ydoc == null || onOpenEditor == null || isInit === true) {
+      return;
+    }
+
+    const ytext = ydoc.getText('codemirror');
+    codeMirrorEditor?.initDoc(ytext.toString());
+    onOpenEditor(ytext.toString());
+
+    setIsInit(true);
+  };
+
+  useEffect(cleanupYDocAndProvider, [cPageId, pageId, provider, socket, ydoc]);
+  useEffect(setupYDoc, [provider, ydoc]);
+  useEffect(setupProvider, [initialValue, pageId, provider, socket, userName, ydoc]);
+  useEffect(setupYDocExtensions, [codeMirrorEditor, provider, ydoc]);
+  useEffect(initializeEditor, [codeMirrorEditor, isInit, onOpenEditor, ydoc]);
+};

+ 3 - 0
packages/preset-themes/src/consts/preset-themes.ts

@@ -18,6 +18,7 @@ export const PresetThemes = {
   NATURE: 'nature',
   SPRING: 'spring',
   WOOD: 'wood',
+  CLASSIC: 'classic',
 } as const;
 export type PresetThemes = typeof PresetThemes[keyof typeof PresetThemes];
 
@@ -45,6 +46,8 @@ export const PresetThemesMetadatas: GrowiThemeMetadata[] = [
     name: PresetThemes.FIRE_RED,      schemeType: BOTH, bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', accent: '#EA5532',
   }, {
     name: PresetThemes.JADE_GREEN,    schemeType: BOTH, bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', accent: '#38B48B',
+  }, {
+    name: PresetThemes.CLASSIC,    schemeType: BOTH, bg: '#FDFDFD', topbar: '#E1E9F4', sidebar: '#E1E9F4', accent: '#439FD8',
   },
   // light only
   {

+ 67 - 0
packages/preset-themes/src/styles/classic.scss

@@ -0,0 +1,67 @@
+:root[data-bs-theme='light'] {
+  @import '@growi/core/scss/bootstrap/init-stage-1';
+  @import '@growi/core/scss/bootstrap/theming/variables';
+  @import '@growi/core/scss/bootstrap/theming/utils/color-palette';
+
+  $primary: #3491CB;
+  $highlight: #B4CAE5;
+
+  @include generate-color-palette('primary', $primary, black, white);
+  @include generate-color-palette('highlight', $highlight,black, white);
+
+  $body-color:                #112744;
+  $body-bg:                   white;
+
+  $body-secondary-color:      rgba($body-color, .75);
+  $body-secondary-bg:         $gray-200;
+
+  $body-tertiary-color:       rgba($body-color, .5);
+  $body-tertiary-bg:          $gray-100;
+
+  $border-color:              var(--grw-highlight-200);
+
+  $link-color:                rgba(25, 56, 186, 1);
+
+  @import 'bootstrap/scss/variables';
+  @import 'bootstrap/scss/variables-dark';
+
+  @import '@growi/core/scss/bootstrap/init-stage-2';
+
+  @import '@growi/core/scss/bootstrap/theming/apply-light';
+
+  --grw-sidebar-nav-btn-color: var(--grw-highlight-600);
+}
+
+:root[data-bs-theme='dark'] {
+  @import '@growi/core/scss/bootstrap/init-stage-1';
+  @import '@growi/core/scss/bootstrap/theming/variables';
+  @import '@growi/core/scss/bootstrap/theming/utils/color-palette';
+
+  $primary: #DB17C2;
+  $highlight: #68829D;
+
+  @include generate-color-palette('primary', $primary, black, white);
+  @include generate-color-palette('highlight', $highlight, black, white);
+
+  $body-color-dark:                   $gray-300;
+  $body-bg-dark:                      #1c1a1a;
+
+  $body-secondary-color-dark:         rgba($body-color-dark, .75);
+  $body-secondary-bg-dark:            $gray-800;
+
+  $body-tertiary-color-dark:          rgba($body-color-dark, .5);
+  $body-tertiary-bg-dark:             mix($gray-800, $gray-900, 50%);
+
+  $border-color-dark:                 var(--grw-highlight-200);
+
+  $link-color-dark:                   mix(#68829D, white, 80%);
+
+  @import 'bootstrap/scss/variables';
+  @import 'bootstrap/scss/variables-dark';
+
+  @import '@growi/core/scss/bootstrap/init-stage-2';
+
+  @import '@growi/core/scss/bootstrap/theming/apply-dark';
+
+  --grw-sidebar-nav-btn-color: rgba(var(--grw-highlight-400-rgb), 0.8);
+}

+ 1 - 0
packages/preset-themes/vite.themes.config.ts

@@ -25,6 +25,7 @@ export default defineConfig(({ mode }) => {
           '/src/styles/nature.scss',
           '/src/styles/spring.scss',
           '/src/styles/wood.scss',
+          '/src/styles/classic.scss',
         ],
         output: {
           assetFileNames: isProd

Plik diff jest za duży
+ 304 - 309
yarn.lock


Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików