Explorar o código

Merge pull request #8793 from weseek/feat/144499-make-the-page-editor-mode-manager-circle-active-when-the-editor-is-open

feat: Make the PageEditorMode manager circle active when the editor is open
Yuki Takei hai 1 ano
pai
achega
2f72599bbf

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

@@ -1,28 +0,0 @@
-import { useCallback, useEffect } from 'react';
-
-import { useGlobalSocket } from '@growi/core/dist/swr';
-
-import type { CurrentPageYjsDraft } from '~/interfaces/page';
-import { SocketEventName } from '~/interfaces/websocket';
-import { useCurrentPageYjsDraft } from '~/stores/page';
-
-export const useYjsDraftEffect = (): void => {
-  const { mutate: mutateeCurrentPageYjsDraft } = useCurrentPageYjsDraft();
-  const { data: socket } = useGlobalSocket();
-
-  const yjsDraftUpdateHandler = useCallback(((currentPageYjsDraft: CurrentPageYjsDraft) => {
-    mutateeCurrentPageYjsDraft(currentPageYjsDraft);
-  }), [mutateeCurrentPageYjsDraft]);
-
-  useEffect(() => {
-
-    if (socket == null) { return }
-
-    socket.on(SocketEventName.YjsUpdated, yjsDraftUpdateHandler);
-
-    return () => {
-      socket.off(SocketEventName.YjsUpdated, yjsDraftUpdateHandler);
-    };
-
-  }, [mutateeCurrentPageYjsDraft, socket, yjsDraftUpdateHandler]);
-};

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

@@ -0,0 +1,50 @@
+import { useCallback, useEffect } from 'react';
+
+import { useGlobalSocket } from '@growi/core/dist/swr';
+
+import { SocketEventName } from '~/interfaces/websocket';
+import { type CurrentPageYjsDraft } from '~/interfaces/yjs';
+import { useCurrentPageYjsData } from '~/stores/yjs';
+
+export const useYjsDraftEffect = (): void => {
+  const { data: socket } = useGlobalSocket();
+  const { updateHasDraft } = useCurrentPageYjsData();
+
+  const yjsDraftUpdateHandler = useCallback(((currentPageYjsDraft: CurrentPageYjsDraft) => {
+    updateHasDraft(currentPageYjsDraft.hasYjsDraft);
+  }), [updateHasDraft]);
+
+  useEffect(() => {
+
+    if (socket == null) { return }
+
+    socket.on(SocketEventName.YjsDraftUpdated, yjsDraftUpdateHandler);
+
+    return () => {
+      socket.off(SocketEventName.YjsDraftUpdated, yjsDraftUpdateHandler);
+    };
+
+  }, [socket, yjsDraftUpdateHandler]);
+};
+
+export const useYjsAwarenessStateEffect = (): void => {
+  const { data: socket } = useGlobalSocket();
+  const { updateAwarenessStateSize } = useCurrentPageYjsData();
+
+  const yjsAwarenessStateUpdateHandler = useCallback(((awarenessStateSize: number) => {
+    updateAwarenessStateSize(awarenessStateSize);
+  }), [updateAwarenessStateSize]);
+
+  useEffect(() => {
+
+    if (socket == null) { return }
+
+    socket.on(SocketEventName.YjsAwarenessStateUpdated, yjsAwarenessStateUpdateHandler);
+
+    return () => {
+      socket.off(SocketEventName.YjsAwarenessStateUpdated, yjsAwarenessStateUpdateHandler);
+    };
+
+  }, [socket, yjsAwarenessStateUpdateHandler]);
+
+};

+ 13 - 4
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -1,12 +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, useCurrentPageYjsDraft } from '~/stores/page';
+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';
 
@@ -64,7 +65,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
   const { data: isNotFound } = useIsNotFound();
   const { mutate: mutateEditorMode } = useEditorMode();
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
-  const { data: currentPageYjsDraft } = useCurrentPageYjsDraft();
+  const { data: currentPageYjsData } = useCurrentPageYjsData();
 
   const { isCreating, createAndTransit } = useCreatePageAndTransit();
 
@@ -87,6 +88,14 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
 
   const _isBtnDisabled = isCreating || isBtnDisabled;
 
+  const circleColor = useMemo(() => {
+    if (currentPageYjsData?.awarenessStateSize != null && currentPageYjsData.awarenessStateSize > 0) {
+      return 'bg-primary';
+    }
+
+    // TODO: https://redmine.weseek.co.jp/issues/145652
+  }, [currentPageYjsData]);
+
   return (
     <>
       <div
@@ -113,7 +122,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
             onClick={editButtonClickedHandler}
           >
             <span className="material-symbols-outlined me-1 fs-5">edit_square</span>{t('Edit')}
-            { currentPageYjsDraft?.hasYjsDraft && <span className="position-absolute top-0 start-100 translate-middle p-1 bg-primary rounded-circle" />}
+            { circleColor != null && <span className={`position-absolute top-0 start-100 translate-middle p-1 rounded-circle ${circleColor}`} />}
           </PageEditorModeButton>
         )}
       </div>

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

@@ -4,7 +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 { useYjsDraftEffect } from '~/client/services/side-effects/yjs-draft';
+import { useYjsDraftEffect, useYjsAwarenessStateEffect } from '~/client/services/side-effects/yjs';
 import { useIsEditable } from '~/stores/context';
 import { useIsLatestRevision } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
@@ -28,6 +28,7 @@ export const DisplaySwitcher = (props: Props): JSX.Element => {
   usePageUpdatedEffect();
   useHashChangedEffect();
   useYjsDraftEffect();
+  useYjsAwarenessStateEffect();
 
   return (
     <>

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

@@ -75,11 +75,3 @@ export type IOptionsForCreate = {
   origin?: Origin
   wip?: boolean,
 };
-
-export type CurrentPageYjsDraft = {
-  hasYjsDraft: boolean,
-}
-
-export const CurrentPageYjsDraftData = {
-  hasYjsDraft: { hasYjsDraft: true },
-};

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

@@ -50,7 +50,8 @@ export const SocketEventName = {
   PageDeleted: 'page:delete',
 
   // Yjs
-  YjsUpdated: 'yjsDraft:update',
+  YjsDraftUpdated: 'yjs:draft-update',
+  YjsAwarenessStateUpdated: 'yjs:awareness-state-update',
 } as const;
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 

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

@@ -0,0 +1,5 @@
+export type CurrentPageYjsDraft = {
+  hasYjsDraft: boolean,
+}
+
+export const CurrentPageYjsDraftData = { hasYjsDraft: true };

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

@@ -43,12 +43,13 @@ import {
 } from '~/stores/context';
 import { useEditingMarkdown } from '~/stores/editor';
 import {
-  useSWRxCurrentPage, useSWRMUTxCurrentPage, useCurrentPageId, useCurrentPageYjsDraft,
+  useSWRxCurrentPage, useSWRMUTxCurrentPage, useCurrentPageId,
   useIsNotFound, useIsLatestRevision, useTemplateTagData, useTemplateBodyData,
 } from '~/stores/page';
 import { useRedirectFrom } from '~/stores/page-redirect';
 import { useRemoteRevisionId } from '~/stores/remote-latest-page';
 import { useSetupGlobalSocket, useSetupGlobalSocketForPage } from '~/stores/websocket';
+import { useCurrentPageYjsData, type CurrentPageYjsDataStates } from '~/stores/yjs';
 import loggerFactory from '~/utils/logger';
 
 import { BasicLayout } from '../components/Layout/BasicLayout';
@@ -172,7 +173,7 @@ type Props = CommonProps & {
   skipSSR: boolean,
   ssrMaxRevisionBodyLength: number,
 
-  hasYjsDraft: boolean,
+  yjsData: CurrentPageYjsDataStates,
 
   rendererConfig: RendererConfig,
 };
@@ -224,8 +225,6 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   useIsUploadAllFileAllowed(props.isUploadAllFileAllowed);
   useIsUploadEnabled(props.isUploadEnabled);
 
-  useCurrentPageYjsDraft({ hasYjsDraft: props.hasYjsDraft });
-
   const { pageWithMeta } = props;
 
   const pageId = pageWithMeta?.data._id;
@@ -248,6 +247,8 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   const { mutate: mutateTemplateTagData } = useTemplateTagData();
   const { mutate: mutateTemplateBodyData } = useTemplateBodyData();
 
+  const { mutate: mutateCurrentPageYjsData } = useCurrentPageYjsData();
+
   useSetupGlobalSocket();
   useSetupGlobalSocketForPage(pageId);
 
@@ -310,6 +311,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;
@@ -490,7 +495,10 @@ async function injectRoutingInformation(context: GetServerSidePropsContext, prop
       }
     }
 
-    props.hasYjsDraft = crowi.pageService.hasYjsDraft(page._id);
+    props.yjsData = {
+      hasDraft: crowi.pageService.hasYjsDraft(page._id),
+      awarenessStateSize: crowi.pageService.getYjsAwarenessStateSize(page._id),
+    };
   }
 }
 

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

@@ -4454,6 +4454,12 @@ class PageService implements IPageService {
     return currentYdoc != null;
   }
 
+  getYjsAwarenessStateSize(pageId: string): number {
+    const yjsConnectionManager = getYjsConnectionManager();
+    const currentYdoc = yjsConnectionManager.getCurrentYdoc(pageId);
+    return currentYdoc?.awareness.states.size ?? 0;
+  }
+
   async createTtlIndex(): Promise<void> {
     const wipPageExpirationSeconds = configManager.getConfig('crowi', 'app:wipPageExpirationSeconds') ?? 172800;
     const collection = mongoose.connection.collection('pages');

+ 12 - 3
apps/app/src/server/service/socket-io.js

@@ -1,13 +1,13 @@
 import { GlobalSocketEventName } from '@growi/core/dist/interfaces';
 import { Server } from 'socket.io';
 
-import { CurrentPageYjsDraftData } from '~/interfaces/page';
 import { SocketEventName } from '~/interfaces/websocket';
+import { CurrentPageYjsDraftData } from '~/interfaces/yjs';
 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');
@@ -170,12 +170,21 @@ class SocketIoService {
   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;
+        this.io
+          .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
+          .emit(SocketEventName.YjsAwarenessStateUpdated, awarenessStateSize);
+      });
+
       socket.on(GlobalSocketEventName.YDocSync, async({ pageId, initialValue }) => {
 
         // Emit to the client in the room of the target pageId.
         this.io
           .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
-          .emit(SocketEventName.YjsUpdated, CurrentPageYjsDraftData.hasYjsDraft);
+          .emit(SocketEventName.YjsDraftUpdated, CurrentPageYjsDraftData);
 
         try {
           await yjsConnectionManager.handleYDocSync(pageId, initialValue);

+ 11 - 2
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();
@@ -90,7 +99,7 @@ class YjsConnectionManager {
     Y.encodeStateAsUpdate(currentYdoc);
   }
 
-  public getCurrentYdoc(pageId: string): Y.Doc | undefined {
+  public getCurrentYdoc(pageId: string): Ydoc | undefined {
     const currentYdoc = this.ysocketio.documents.get(`yjs/${pageId}`);
     return currentYdoc;
   }

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

@@ -18,7 +18,6 @@ import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
 
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
-import type { CurrentPageYjsDraft } from '~/interfaces/page';
 import type { IRecordApplicableGrant, IResIsGrantNormalized } from '~/interfaces/page-grant';
 import type { AxiosResponse } from '~/utils/axios';
 
@@ -54,10 +53,6 @@ export const useTemplateBodyData = (initialData?: string): SWRResponse<string, E
   return useSWRStatic<string, Error>('templateBodyData', initialData);
 };
 
-export const useCurrentPageYjsDraft = (initialData?: CurrentPageYjsDraft): SWRResponse<CurrentPageYjsDraft, Error> => {
-  return useSWRStatic<CurrentPageYjsDraft, Error>('currentPageYjsDraft', initialData);
-};
-
 /** "useSWRxCurrentPage" is intended for initial data retrieval only. Use "useSWRMUTxCurrentPage" for revalidation */
 export const useSWRxCurrentPage = (initialData?: IPagePopulatedToShowRevision|null): SWRResponse<IPagePopulatedToShowRevision|null> => {
   const key = 'currentPage';

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

@@ -0,0 +1,28 @@
+import { useCallback } from 'react';
+
+import { useSWRStatic } from '@growi/core/dist/swr';
+import { type SWRResponse } from 'swr';
+
+export type CurrentPageYjsDataStates = {
+  hasDraft?: boolean,
+  awarenessStateSize?: number,
+}
+
+type CurrentPageYjsDataUtils = {
+  updateHasDraft(hasYjsDraft: boolean): void
+  updateAwarenessStateSize(awarenessStateSize: number): void
+}
+
+export const useCurrentPageYjsData = (): SWRResponse<CurrentPageYjsDataStates, Error> & CurrentPageYjsDataUtils => {
+  const swrResponse = useSWRStatic<CurrentPageYjsDataStates, Error>('currentPageYjsData', undefined);
+
+  const updateHasDraft = useCallback((hasDraft: boolean) => {
+    swrResponse.mutate({ ...swrResponse.data, hasDraft });
+  }, [swrResponse]);
+
+  const updateAwarenessStateSize = useCallback((awarenessStateSize: number) => {
+    swrResponse.mutate({ ...swrResponse.data, awarenessStateSize });
+  }, [swrResponse]);
+
+  return { ...swrResponse, updateHasDraft, updateAwarenessStateSize };
+};