Răsfoiți Sursa

Merge pull request #8794 from weseek/feat/145652-make-the-page-editor-mode-manager-circle-muted-when-there-is-a-difference-between-the-revision-body-and-the-draft

feat: Show the indicator that there is a difference between RevisionBody and Yjs draft
Yuki Takei 1 an în urmă
părinte
comite
2bd22141a1

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

@@ -3,35 +3,17 @@ 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 => {
+export const useCurrentPageYjsDataEffect = (): void => {
   const { data: socket } = useGlobalSocket();
-  const { updateHasDraft } = useCurrentPageYjsData();
+  const { updateHasRevisionBodyDiff, updateAwarenessStateSize } = useCurrentPageYjsData();
 
-  const yjsDraftUpdateHandler = useCallback(((currentPageYjsDraft: CurrentPageYjsDraft) => {
-    updateHasDraft(currentPageYjsDraft.hasYjsDraft);
-  }), [updateHasDraft]);
+  const hasRevisionBodyDiffUpdateHandler = useCallback((hasRevisionBodyDiff: boolean) => {
+    updateHasRevisionBodyDiff(hasRevisionBodyDiff);
+  }, [updateHasRevisionBodyDiff]);
 
-  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) => {
+  const awarenessStateSizeUpdateHandler = useCallback(((awarenessStateSize: number) => {
     updateAwarenessStateSize(awarenessStateSize);
   }), [updateAwarenessStateSize]);
 
@@ -39,12 +21,13 @@ export const useYjsAwarenessStateEffect = (): void => {
 
     if (socket == null) { return }
 
-    socket.on(SocketEventName.YjsAwarenessStateUpdated, yjsAwarenessStateUpdateHandler);
+    socket.on(SocketEventName.YjsHasRevisionBodyDiffUpdated, hasRevisionBodyDiffUpdateHandler);
+    socket.on(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSizeUpdateHandler);
 
     return () => {
-      socket.off(SocketEventName.YjsAwarenessStateUpdated, yjsAwarenessStateUpdateHandler);
+      socket.off(SocketEventName.YjsHasRevisionBodyDiffUpdated, hasRevisionBodyDiffUpdateHandler);
+      socket.off(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSizeUpdateHandler);
     };
 
-  }, [socket, yjsAwarenessStateUpdateHandler]);
-
+  }, [socket, awarenessStateSizeUpdateHandler, hasRevisionBodyDiffUpdateHandler]);
 };

+ 3 - 1
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -93,7 +93,9 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
       return 'bg-primary';
     }
 
-    // TODO: https://redmine.weseek.co.jp/issues/145652
+    if (currentPageYjsData?.hasRevisionBodyDiff != null && currentPageYjsData.hasRevisionBodyDiff) {
+      return 'bg-secondary';
+    }
   }, [currentPageYjsData]);
 
   return (

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

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

@@ -50,8 +50,8 @@ export const SocketEventName = {
   PageDeleted: 'page:delete',
 
   // Yjs
-  YjsDraftUpdated: 'yjs:draft-update',
-  YjsAwarenessStateUpdated: 'yjs:awareness-state-update',
+  YjsAwarenessStateSizeUpdated: 'yjs:awareness-state-size-update',
+  YjsHasRevisionBodyDiffUpdated: 'yjs:has-revision-body-diff-update',
 } as const;
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 

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

@@ -1,5 +1,4 @@
-export type CurrentPageYjsDraft = {
-  hasYjsDraft: boolean,
+export type CurrentPageYjsData = {
+  hasRevisionBodyDiff?: boolean,
+  awarenessStateSize?: number,
 }
-
-export const CurrentPageYjsDraftData = { hasYjsDraft: true };

+ 10 - 7
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,7 +50,7 @@ import {
 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 { useCurrentPageYjsData, useSWRMUTxCurrentPageYjsData } from '~/stores/yjs';
 import loggerFactory from '~/utils/logger';
 
 import { BasicLayout } from '../components/Layout/BasicLayout';
@@ -173,7 +174,7 @@ type Props = CommonProps & {
   skipSSR: boolean,
   ssrMaxRevisionBodyLength: number,
 
-  yjsData: CurrentPageYjsDataStates,
+  yjsData: CurrentPageYjsData,
 
   rendererConfig: RendererConfig,
 };
@@ -235,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();
 
@@ -262,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(() => {
@@ -495,10 +499,9 @@ async function injectRoutingInformation(context: GetServerSidePropsContext, prop
       }
     }
 
-    props.yjsData = {
-      hasDraft: crowi.pageService.hasYjsDraft(page._id),
-      awarenessStateSize: crowi.pageService.getYjsAwarenessStateSize(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;
 };

+ 22 - 6
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,
@@ -4448,16 +4449,31 @@ class PageService implements IPageService {
     });
   }
 
-  hasYjsDraft(pageId: string): boolean {
+  async getYjsData(pageId: string): Promise<CurrentPageYjsData> {
     const yjsConnectionManager = getYjsConnectionManager();
     const currentYdoc = yjsConnectionManager.getCurrentYdoc(pageId);
-    return currentYdoc != null;
+    const yjsDraft = currentYdoc?.getText('codemirror').toString();
+    const hasRevisionBodyDiff = await this.hasRevisionBodyDiff(pageId, yjsDraft);
+
+    return {
+      hasRevisionBodyDiff,
+      awarenessStateSize: currentYdoc?.awareness.states.size,
+    };
   }
 
-  getYjsAwarenessStateSize(pageId: string): number {
-    const yjsConnectionManager = getYjsConnectionManager();
-    const currentYdoc = yjsConnectionManager.getCurrentYdoc(pageId);
-    return currentYdoc?.awareness.states.size ?? 0;
+  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> {

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

+ 14 - 8
apps/app/src/server/service/socket-io.js

@@ -2,7 +2,6 @@ import { GlobalSocketEventName } from '@growi/core/dist/interfaces';
 import { Server } from 'socket.io';
 
 import { SocketEventName } from '~/interfaces/websocket';
-import { CurrentPageYjsDraftData } from '~/interfaces/yjs';
 import loggerFactory from '~/utils/logger';
 
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
@@ -169,23 +168,30 @@ 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;
+
+        // Triggered when awareness changes
         this.io
           .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
-          .emit(SocketEventName.YjsAwarenessStateUpdated, awarenessStateSize);
+          .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 }) => {
-
-        // Emit to the client in the room of the target pageId.
-        this.io
-          .in(getRoomNameWithId(RoomPrefix.PAGE, pageId))
-          .emit(SocketEventName.YjsDraftUpdated, CurrentPageYjsDraftData);
-
         try {
           await yjsConnectionManager.handleYDocSync(pageId, initialValue);
         }

+ 25 - 11
apps/app/src/stores/yjs.ts

@@ -1,28 +1,42 @@
 import { useCallback } from 'react';
 
 import { useSWRStatic } from '@growi/core/dist/swr';
-import { type SWRResponse } from 'swr';
+import type { SWRResponse } from 'swr';
+import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
 
-export type CurrentPageYjsDataStates = {
-  hasDraft?: boolean,
-  awarenessStateSize?: number,
-}
+import { apiv3Get } from '~/client/util/apiv3-client';
+import type { CurrentPageYjsData } from '~/interfaces/yjs';
+
+import { useCurrentPageId } from './page';
 
 type CurrentPageYjsDataUtils = {
-  updateHasDraft(hasYjsDraft: boolean): void
+  updateHasRevisionBodyDiff(hasRevisionBodyDiff: boolean): void
   updateAwarenessStateSize(awarenessStateSize: number): void
 }
 
-export const useCurrentPageYjsData = (): SWRResponse<CurrentPageYjsDataStates, Error> & CurrentPageYjsDataUtils => {
-  const swrResponse = useSWRStatic<CurrentPageYjsDataStates, Error>('currentPageYjsData', undefined);
+export const useCurrentPageYjsData = (): SWRResponse<CurrentPageYjsData, Error> & CurrentPageYjsDataUtils => {
+  const swrResponse = useSWRStatic<CurrentPageYjsData, Error>('currentPageYjsData', undefined);
 
-  const updateHasDraft = useCallback((hasDraft: boolean) => {
-    swrResponse.mutate({ ...swrResponse.data, hasDraft });
+  const updateHasRevisionBodyDiff = useCallback((hasRevisionBodyDiff: boolean) => {
+    swrResponse.mutate({ ...swrResponse.data, hasRevisionBodyDiff });
   }, [swrResponse]);
 
   const updateAwarenessStateSize = useCallback((awarenessStateSize: number) => {
     swrResponse.mutate({ ...swrResponse.data, awarenessStateSize });
   }, [swrResponse]);
 
-  return { ...swrResponse, updateHasDraft, updateAwarenessStateSize };
+  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 },
+  );
 };