Jelajahi Sumber

Merge branch 'dev/7.0.x' into 126524-139354-support-keybind

reiji-h 2 tahun lalu
induk
melakukan
d232821304
56 mengubah file dengan 986 tambahan dan 551 penghapusan
  1. 1 0
      apps/app/.eslintrc.js
  2. 13 5
      apps/app/package.json
  3. 2 1
      apps/app/src/client/services/side-effects/page-updated.ts
  4. 37 0
      apps/app/src/components/Common/Dropdown/PageItemControl.spec.tsx
  5. 3 5
      apps/app/src/components/Common/Dropdown/PageItemControl.tsx
  6. 1 1
      apps/app/src/components/ItemsTree/ItemsTree.tsx
  7. 21 14
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  8. 68 37
      apps/app/src/components/Navbar/hooks.tsx
  9. 2 3
      apps/app/src/components/PageComment/CommentEditor.tsx
  10. 7 3
      apps/app/src/components/PageDeleteModal.tsx
  11. 0 2
      apps/app/src/components/PageEditor/EditorNavbarBottom.module.scss
  12. 4 4
      apps/app/src/components/PageEditor/EditorNavbarBottom.tsx
  13. 32 35
      apps/app/src/components/PageEditor/PageEditor.tsx
  14. 1 1
      apps/app/src/components/PrivateLegacyPages.tsx
  15. 3 3
      apps/app/src/components/SavePageControls.tsx
  16. 2 2
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  17. 11 4
      apps/app/src/components/Sidebar/PageCreateButton/hooks.tsx
  18. 0 1
      apps/app/src/interfaces/websocket.ts
  19. 26 5
      apps/app/src/pages/[[...path]].page.tsx
  20. 6 1
      apps/app/src/pages/admin/audit-log.page.tsx
  21. 4 19
      apps/app/src/pages/share/[[...path]].page.tsx
  22. 18 0
      apps/app/src/pages/utils/commons.ts
  23. 3 0
      apps/app/src/server/crowi/index.js
  24. 25 10
      apps/app/src/server/service/growi-bridge/index.ts
  25. 22 0
      apps/app/src/server/service/growi-bridge/unzip-stream-utils.ts
  26. 9 4
      apps/app/src/server/service/import.js
  27. 12 0
      apps/app/src/server/service/normalize-data/index.ts
  28. 31 0
      apps/app/src/server/service/normalize-data/rename-duplicate-root-pages.ts
  29. 4 0
      apps/app/src/server/service/page/index.ts
  30. 22 0
      apps/app/src/server/service/socket-io.js
  31. 73 0
      apps/app/src/server/service/yjs-connection-manager.ts
  32. 0 1
      apps/app/src/stores/context.tsx
  33. 2 1
      apps/app/src/stores/ui.tsx
  34. 1 1
      apps/app/src/stores/use-static-swr.ts
  35. 1 7
      apps/app/src/stores/websocket.tsx
  36. 0 46
      apps/app/src/styles/_mixins.scss
  37. 0 1
      apps/app/src/styles/_variables.scss
  38. 16 0
      apps/app/vitest.config.components.ts
  39. 1 1
      apps/app/vitest.config.ts
  40. 1 1
      package.json
  41. 1 0
      packages/core/src/interfaces/index.ts
  42. 1 0
      packages/core/src/interfaces/page.ts
  43. 6 0
      packages/core/src/interfaces/websocket.ts
  44. 1 0
      packages/core/src/swr/index.ts
  45. 11 0
      packages/core/src/swr/use-global-socket.ts
  46. 2 2
      packages/core/src/utils/page-path-utils/index.ts
  47. 4 1
      packages/editor/package.json
  48. 9 3
      packages/editor/src/components/CodeMirrorEditorMain.tsx
  49. 1 0
      packages/editor/src/consts/index.ts
  50. 15 0
      packages/editor/src/consts/ydoc-awareness-user-color.ts
  51. 7 0
      packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts
  52. 10 11
      packages/editor/src/services/editor-theme/original-dark.ts
  53. 7 6
      packages/editor/src/services/editor-theme/original-light.ts
  54. 1 0
      packages/editor/src/stores/index.ts
  55. 121 0
      packages/editor/src/stores/use-collaborative-editor-mode.ts
  56. 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'],

+ 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

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

+ 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}
             editorKeymap={editorSettings?.keymapMode}
           />

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

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

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

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

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

@@ -48,6 +48,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"
   }
 }

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

@@ -6,11 +6,10 @@ import { keymap, scrollPastEnd } from '@codemirror/view';
 import { GlobalCodeMirrorEditorKey, AcceptedUploadFileType } from '../consts';
 import { getKeymap } from '../services';
 import { setDataLine } from '../services/extensions/setDataLine';
-import { useCodeMirrorEditorIsolated } from '../stores';
+import { useCodeMirrorEditorIsolated, useCollaborativeEditorMode } from '../stores';
 
 import { CodeMirrorEditor } from '.';
 
-
 const additionalExtensions: Extension[] = [
   [
     scrollPastEnd(),
@@ -25,16 +24,23 @@ type Props = {
   onScroll?: () => void,
   acceptedFileType?: AcceptedUploadFileType,
   indentSize?: number,
+  userName?: string,
+  pageId?: string,
+  initialValue?: string,
+  onOpenEditor?: (markdown: string) => void,
   editorTheme?: string,
   editorKeymap?: string,
 }
 
 export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
   const {
-    onSave, onChange, onUpload, onScroll, acceptedFileType, indentSize, editorTheme, editorKeymap,
+    onSave, onChange, onUpload, onScroll, acceptedFileType, indentSize, userName, pageId, initialValue, onOpenEditor, editorTheme, editorKeymap,
   } = 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];

+ 7 - 0
packages/editor/src/services/codemirror-editor/use-codemirror-editor/use-codemirror-editor.ts

@@ -9,6 +9,10 @@ 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 +63,7 @@ const defaultExtensions: Extension[] = [
   syntaxHighlighting(markdownHighlighting),
   Prec.lowest(syntaxHighlighting(defaultHighlightStyle)),
   emojiAutocompletionSettings,
+  keymap.of(yUndoManagerKeymap),
 ];
 
 
@@ -81,6 +86,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' },
   ],
 });

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

File diff ditekan karena terlalu besar
+ 304 - 309
yarn.lock


Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini