Ver Fonte

Merge branch 'master' into fix/145550-admin-rayout

satof3 há 1 ano atrás
pai
commit
85eaea00bd
29 ficheiros alterados com 408 adições e 96 exclusões
  1. 1 0
      apps/app/public/static/locales/en_US/commons.json
  2. 1 0
      apps/app/public/static/locales/fr_FR/commons.json
  3. 1 0
      apps/app/public/static/locales/ja_JP/commons.json
  4. 1 0
      apps/app/public/static/locales/zh_CN/commons.json
  5. 33 0
      apps/app/src/client/services/side-effects/yjs.ts
  6. 3 56
      apps/app/src/components/ItemsTree/ItemsTree.tsx
  7. 14 2
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  8. 2 0
      apps/app/src/components/Page/DisplaySwitcher.tsx
  9. 9 0
      apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx
  10. 6 0
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  11. 72 5
      apps/app/src/components/Sidebar/PageTree/PageTreeSubstance.tsx
  12. 17 13
      apps/app/src/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  13. 7 0
      apps/app/src/components/Sidebar/Sidebar.tsx
  14. 3 2
      apps/app/src/components/Sidebar/SidebarNav/SecondaryItems.tsx
  15. 1 1
      apps/app/src/components/TreeItem/ItemNode.ts
  16. 8 0
      apps/app/src/interfaces/websocket.ts
  17. 4 0
      apps/app/src/interfaces/yjs.ts
  18. 18 1
      apps/app/src/pages/[[...path]].page.tsx
  19. 57 0
      apps/app/src/server/routes/apiv3/page/get-yjs-data.ts
  20. 3 0
      apps/app/src/server/routes/apiv3/page/index.ts
  21. 7 1
      apps/app/src/server/service/i18next.ts
  22. 29 0
      apps/app/src/server/service/page/index.ts
  23. 2 0
      apps/app/src/server/service/page/page-service.ts
  24. 35 2
      apps/app/src/server/service/socket-io.js
  25. 20 7
      apps/app/src/server/service/yjs-connection-manager.ts
  26. 1 0
      apps/app/src/stores/page.tsx
  27. 4 4
      apps/app/src/stores/ui.tsx
  28. 7 2
      apps/app/src/stores/websocket.tsx
  29. 42 0
      apps/app/src/stores/yjs.ts

+ 1 - 0
apps/app/public/static/locales/en_US/commons.json

@@ -77,6 +77,7 @@
 
   "create_page_dropdown": {
     "new_page": "Create New Page",
+    "open_page_create_modal": "Open new page create modal",
     "todays": {
       "desc": "Create today's memo",
       "memo": "memo"

+ 1 - 0
apps/app/public/static/locales/fr_FR/commons.json

@@ -77,6 +77,7 @@
 
   "create_page_dropdown": {
     "new_page": "Créer nouvelle page",
+    "open_page_create_modal": "Ouvrir une nouvelle page créer une fenêtre modale",
     "todays": {
       "desc": "Créer le mémo du jour",
       "memo": "mémo"

+ 1 - 0
apps/app/public/static/locales/ja_JP/commons.json

@@ -79,6 +79,7 @@
 
   "create_page_dropdown": {
     "new_page": "新規ページ作成",
+    "open_page_create_modal": "新規ページ作成モーダルを表示",
     "todays": {
       "desc": "今日のメモを作成",
       "memo": "メモ"

+ 1 - 0
apps/app/public/static/locales/zh_CN/commons.json

@@ -80,6 +80,7 @@
 
   "create_page_dropdown": {
     "new_page": "新页面",
+    "open_page_create_modal": "打开新页面创建模式",
     "todays": {
       "desc": "Create today's memo",
       "memo": "memo"

+ 33 - 0
apps/app/src/client/services/side-effects/yjs.ts

@@ -0,0 +1,33 @@
+import { useCallback, useEffect } from 'react';
+
+import { useGlobalSocket } from '@growi/core/dist/swr';
+
+import { SocketEventName } from '~/interfaces/websocket';
+import { useCurrentPageYjsData } from '~/stores/yjs';
+
+export const useCurrentPageYjsDataEffect = (): void => {
+  const { data: socket } = useGlobalSocket();
+  const { updateHasRevisionBodyDiff, updateAwarenessStateSize } = useCurrentPageYjsData();
+
+  const hasRevisionBodyDiffUpdateHandler = useCallback((hasRevisionBodyDiff: boolean) => {
+    updateHasRevisionBodyDiff(hasRevisionBodyDiff);
+  }, [updateHasRevisionBodyDiff]);
+
+  const awarenessStateSizeUpdateHandler = useCallback(((awarenessStateSize: number) => {
+    updateAwarenessStateSize(awarenessStateSize);
+  }), [updateAwarenessStateSize]);
+
+  useEffect(() => {
+
+    if (socket == null) { return }
+
+    socket.on(SocketEventName.YjsHasRevisionBodyDiffUpdated, hasRevisionBodyDiffUpdateHandler);
+    socket.on(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSizeUpdateHandler);
+
+    return () => {
+      socket.off(SocketEventName.YjsHasRevisionBodyDiffUpdated, hasRevisionBodyDiffUpdateHandler);
+      socket.off(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSizeUpdateHandler);
+    };
+
+  }, [socket, awarenessStateSizeUpdateHandler, hasRevisionBodyDiffUpdateHandler]);
+};

+ 3 - 56
apps/app/src/components/ItemsTree/ItemsTree.tsx

@@ -8,7 +8,6 @@ import type { Nullable, IPageHasId, IPageToDeleteWithMeta } from '@growi/core';
 import { useGlobalSocket } from '@growi/core/dist/swr';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
-import { debounce } from 'throttle-debounce';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { IPageForItem } from '~/interfaces/page';
@@ -23,7 +22,7 @@ import {
   useSWRxPageAncestorsChildren, useSWRxRootPage, mutatePageTree, mutatePageList,
 } from '~/stores/page-listing';
 import { mutateSearching } from '~/stores/search';
-import { usePageTreeDescCountMap, useSidebarScrollerRef } from '~/stores/ui';
+import { usePageTreeDescCountMap } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
 import { ItemNode, type TreeItemProps } from '../TreeItem';
@@ -32,6 +31,7 @@ import ItemsTreeContentSkeleton from './ItemsTreeContentSkeleton';
 
 import styles from './ItemsTree.module.scss';
 
+const moduleClass = styles['grw-pagetree'] ?? '';
 
 const logger = loggerFactory('growi:cli:ItemsTree');
 
@@ -115,7 +115,6 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openDeleteModal } = usePageDeleteModal();
-  const { data: sidebarScrollerRef } = useSidebarScrollerRef();
 
   const { data: socket } = useGlobalSocket();
   const { data: ptDescCountMap, update: updatePtDescCountMap } = usePageTreeDescCountMap();
@@ -123,9 +122,6 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   // for mutation
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
 
-  const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false);
-
-  const rootElemRef = useRef(null);
 
   const renderingCondition = useMemo(() => {
     return {
@@ -200,55 +196,6 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
   }, [currentPagePath, mutateCurrentPage, openDeleteModal, router, t]);
 
-  // ***************************  Scroll on init ***************************
-  const scrollOnInit = useCallback(() => {
-    const scrollTargetElement = document.getElementById('grw-pagetree-current-page-item');
-
-    if (sidebarScrollerRef?.current == null || scrollTargetElement == null) {
-      return;
-    }
-
-    logger.debug('scrollOnInit has invoked');
-
-    const scrollElement = sidebarScrollerRef.current.getScrollElement();
-
-    // NOTE: could not use scrollIntoView
-    //  https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move
-
-    // calculate the center point
-    const scrollTop = scrollTargetElement.offsetTop - scrollElement.getBoundingClientRect().height / 2;
-    scrollElement.scrollTo({ top: scrollTop });
-
-    setIsInitialScrollCompleted(true);
-  }, [sidebarScrollerRef]);
-
-  const scrollOnInitDebounced = useMemo(() => debounce(500, scrollOnInit), [scrollOnInit]);
-
-  useEffect(() => {
-    if (!isSecondStageRenderingCondition(renderingCondition) || isInitialScrollCompleted) {
-      return;
-    }
-
-    const rootElement = rootElemRef.current as HTMLElement | null;
-    if (rootElement == null) {
-      return;
-    }
-
-    const observerCallback = (mutationRecords: MutationRecord[]) => {
-      mutationRecords.forEach(() => scrollOnInitDebounced());
-    };
-
-    const observer = new MutationObserver(observerCallback);
-    observer.observe(rootElement, { childList: true, subtree: true });
-
-    // first call for the situation that all rendering is complete at this point
-    scrollOnInitDebounced();
-
-    return () => {
-      observer.disconnect();
-    };
-  }, [isInitialScrollCompleted, renderingCondition, scrollOnInitDebounced]);
-  // *******************************  end  *******************************
 
   if (error1 != null || error2 != null) {
     // TODO: improve message
@@ -275,7 +222,7 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
 
   if (initialItemNode != null) {
     return (
-      <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group py-4`} ref={rootElemRef}>
+      <ul className={`grw-pagetree ${moduleClass} list-group py-4`}>
         <CustomTreeItem
           key={initialItemNode.page.path}
           targetPathOrId={targetPathOrId}

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

@@ -1,13 +1,13 @@
-import React, { type ReactNode, useCallback } from 'react';
+import React, { type ReactNode, useCallback, useMemo } from 'react';
 
 import { Origin } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
-
 import { useCreatePageAndTransit } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
 import { useIsNotFound } from '~/stores/page';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
+import { useCurrentPageYjsData } from '~/stores/yjs';
 
 import { shouldCreateWipPage } from '../../utils/should-create-wip-page';
 
@@ -65,6 +65,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
   const { data: isNotFound } = useIsNotFound();
   const { mutate: mutateEditorMode } = useEditorMode();
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
+  const { data: currentPageYjsData } = useCurrentPageYjsData();
 
   const { isCreating, createAndTransit } = useCreatePageAndTransit();
 
@@ -87,6 +88,16 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
 
   const _isBtnDisabled = isCreating || isBtnDisabled;
 
+  const circleColor = useMemo(() => {
+    if (currentPageYjsData?.awarenessStateSize != null && currentPageYjsData.awarenessStateSize > 0) {
+      return 'bg-primary';
+    }
+
+    if (currentPageYjsData?.hasRevisionBodyDiff != null && currentPageYjsData.hasRevisionBodyDiff) {
+      return 'bg-secondary';
+    }
+  }, [currentPageYjsData]);
+
   return (
     <>
       <div
@@ -113,6 +124,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
             onClick={editButtonClickedHandler}
           >
             <span className="material-symbols-outlined me-1 fs-5">edit_square</span>{t('Edit')}
+            { circleColor != null && <span className={`position-absolute top-0 start-100 translate-middle p-1 rounded-circle ${circleColor}`} />}
           </PageEditorModeButton>
         )}
       </div>

+ 2 - 0
apps/app/src/components/Page/DisplaySwitcher.tsx

@@ -4,6 +4,7 @@ import dynamic from 'next/dynamic';
 
 import { useHashChangedEffect } from '~/client/services/side-effects/hash-changed';
 import { usePageUpdatedEffect } from '~/client/services/side-effects/page-updated';
+import { useCurrentPageYjsDataEffect } from '~/client/services/side-effects/yjs';
 import { useIsEditable } from '~/stores/context';
 import { useIsLatestRevision } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
@@ -26,6 +27,7 @@ export const DisplaySwitcher = (props: Props): JSX.Element => {
 
   usePageUpdatedEffect();
   useHashChangedEffect();
+  useCurrentPageYjsDataEffect();
 
   return (
     <>

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

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

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

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

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

@@ -1,13 +1,20 @@
-import React, { memo, useCallback } from 'react';
+import React, {
+  memo, useCallback, useEffect, useMemo, useRef, useState,
+} from 'react';
 
 import { useTranslation } from 'next-i18next';
 import {
   UncontrolledButtonDropdown, DropdownMenu, DropdownToggle, DropdownItem,
 } from 'reactstrap';
+import { debounce } from 'throttle-debounce';
 
 import { useTargetAndAncestors, useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/page';
-import { mutatePageTree, useSWRxRootPage, useSWRxV5MigrationStatus } from '~/stores/page-listing';
+import {
+  mutatePageTree, useSWRxPageAncestorsChildren, useSWRxRootPage, useSWRxV5MigrationStatus,
+} from '~/stores/page-listing';
+import { useSidebarScrollerRef } from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
 
 import { ItemsTree } from '../../ItemsTree/ItemsTree';
 import { PageTreeItem } from '../PageTreeItem';
@@ -15,6 +22,8 @@ import { SidebarHeaderReloadButton } from '../SidebarHeaderReloadButton';
 
 import { PrivateLegacyPagesLink } from './PrivateLegacyPagesLink';
 
+const logger = loggerFactory('growi:cli:PageTreeSubstance');
+
 type HeaderProps = {
   isWipPageShown: boolean,
   onWipPageShownChange?: () => void
@@ -91,6 +100,65 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
   const { data: migrationStatus } = useSWRxV5MigrationStatus({ suspense: true });
 
   const targetPathOrId = targetId || currentPath;
+  const path = currentPath || '/';
+
+  const { data: ancestorsChildrenResult } = useSWRxPageAncestorsChildren(path, { suspense: true });
+  const { data: rootPageResult } = useSWRxRootPage({ suspense: true });
+  const { data: sidebarScrollerRef } = useSidebarScrollerRef();
+  const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false);
+
+  const rootElemRef = useRef(null);
+
+  // ***************************  Scroll on init ***************************
+  const scrollOnInit = useCallback(() => {
+    const scrollTargetElement = document.getElementById('grw-pagetree-current-page-item');
+
+    if (sidebarScrollerRef?.current == null || scrollTargetElement == null) {
+      return;
+    }
+
+    logger.debug('scrollOnInit has invoked');
+
+    const scrollElement = sidebarScrollerRef.current;
+
+    // NOTE: could not use scrollIntoView
+    //  https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move
+
+    // calculate the center point
+    const scrollTop = scrollTargetElement.offsetTop - scrollElement.getBoundingClientRect().height / 2;
+    scrollElement.scrollTo({ top: scrollTop });
+
+    setIsInitialScrollCompleted(true);
+  }, [sidebarScrollerRef]);
+
+  const scrollOnInitDebounced = useMemo(() => debounce(500, scrollOnInit), [scrollOnInit]);
+
+  useEffect(() => {
+    if (isInitialScrollCompleted || ancestorsChildrenResult == null || rootPageResult == null) {
+      return;
+    }
+
+    const rootElement = rootElemRef.current as HTMLElement | null;
+    if (rootElement == null) {
+      return;
+    }
+
+    const observerCallback = (mutationRecords: MutationRecord[]) => {
+      mutationRecords.forEach(() => scrollOnInitDebounced());
+    };
+
+    const observer = new MutationObserver(observerCallback);
+    observer.observe(rootElement, { childList: true, subtree: true });
+
+    // first call for the situation that all rendering is complete at this point
+    scrollOnInitDebounced();
+
+    return () => {
+      observer.disconnect();
+    };
+  }, [isInitialScrollCompleted, scrollOnInitDebounced, ancestorsChildrenResult, rootPageResult]);
+  // *******************************  end  *******************************
+
 
   if (!migrationStatus?.isV5Compatible) {
     return <PageTreeUnavailable />;
@@ -103,10 +171,9 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
     return null;
   }
 
-  const path = currentPath || '/';
 
   return (
-    <>
+    <div ref={rootElemRef}>
       <ItemsTree
         isEnableActions={!isGuestUser}
         isReadOnlyUser={!!isReadOnlyUser}
@@ -124,7 +191,7 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
           </div>
         </div>
       )}
-    </>
+    </div>
   );
 });
 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 7 - 1
apps/app/src/server/service/i18next.ts

@@ -1,3 +1,5 @@
+import path from 'path';
+
 import type { Lang } from '@growi/core';
 import type { TFunction, i18n } from 'i18next';
 import { createInstance } from 'i18next';
@@ -5,16 +7,20 @@ import resourcesToBackend from 'i18next-resources-to-backend';
 
 import { defaultLang, initOptions } from '^/config/i18next.config';
 
+import { resolveFromRoot } from '~/utils/project-dir-utils';
+
 import { configManager } from './config-manager';
 
 
+const relativePathToLocalesRoot = path.relative(__dirname, resolveFromRoot('public/static/locales'));
+
 const initI18next = async(lang: Lang = defaultLang) => {
   const i18nInstance = createInstance();
   await i18nInstance
     .use(
       resourcesToBackend(
         (language: string, namespace: string) => {
-          return import(`^/public/static/locales/${language}/${namespace}.json`);
+          return import(path.join(relativePathToLocalesRoot, language, `${namespace}.json`));
         },
       ),
     )

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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