Yuki Takei пре 6 месеци
родитељ
комит
b0cf8041e7

+ 1 - 0
apps/app/.eslintrc.js

@@ -32,6 +32,7 @@ module.exports = {
     'src/linter-checker/**',
     'src/linter-checker/**',
     'src/migrations/**',
     'src/migrations/**',
     'src/features/callout/**',
     'src/features/callout/**',
+    'src/features/collaborative-editor/**',
     'src/features/comment/**',
     'src/features/comment/**',
     'src/features/templates/**',
     'src/features/templates/**',
     'src/features/mermaid/**',
     'src/features/mermaid/**',

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

@@ -8,10 +8,10 @@ import { useTranslation } from 'next-i18next';
 
 
 import { useCreatePage } from '~/client/services/create-page';
 import { useCreatePage } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
+import { useCurrentPageYjsData } from '~/features/collaborative-editor/states';
 import { usePageNotFound } from '~/states/page';
 import { usePageNotFound } from '~/states/page';
 import { useDeviceLargerThanMd } from '~/states/ui/device';
 import { useDeviceLargerThanMd } from '~/states/ui/device';
 import { useEditorMode, EditorMode } from '~/states/ui/editor';
 import { useEditorMode, EditorMode } from '~/states/ui/editor';
-import { useCurrentPageYjsData } from '~/stores/yjs';
 
 
 import { shouldCreateWipPage } from '../../../utils/should-create-wip-page';
 import { shouldCreateWipPage } from '../../../utils/should-create-wip-page';
 
 
@@ -69,7 +69,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
   const isNotFound = usePageNotFound();
   const isNotFound = usePageNotFound();
   const { setEditorMode } = useEditorMode();
   const { setEditorMode } = useEditorMode();
   const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
   const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
-  const { data: currentPageYjsData } = useCurrentPageYjsData();
+  const currentPageYjsData = useCurrentPageYjsData();
 
 
   const { isCreating, create } = useCreatePage();
   const { isCreating, create } = useCreatePage();
 
 

+ 5 - 2
apps/app/src/client/components/Page/EditablePageEffects.tsx

@@ -1,13 +1,16 @@
 import type { JSX } from 'react';
 import type { JSX } from 'react';
 
 
 import { usePageUpdatedEffect } from '~/client/services/side-effects/page-updated';
 import { usePageUpdatedEffect } from '~/client/services/side-effects/page-updated';
-import { useCurrentPageYjsDataEffect } from '~/client/services/side-effects/yjs';
+import { useAwarenessSyncingEffect, useNewlyYjsDataSyncingEffect, useCurrentPageYjsDataAutoLoadEffect } from '~/features/collaborative-editor/side-effects';
 
 
 
 
 export const EditablePageEffects = (): JSX.Element => {
 export const EditablePageEffects = (): JSX.Element => {
 
 
   usePageUpdatedEffect();
   usePageUpdatedEffect();
-  useCurrentPageYjsDataEffect();
+
+  useCurrentPageYjsDataAutoLoadEffect();
+  useNewlyYjsDataSyncingEffect();
+  useAwarenessSyncingEffect();
 
 
   return <></>;
   return <></>;
 
 

+ 2 - 2
apps/app/src/client/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx

@@ -9,10 +9,10 @@ import {
 } from '@growi/remark-drawio';
 } from '@growi/remark-drawio';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
+import { useCurrentPageYjsData } from '~/features/collaborative-editor/states';
 import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
 import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
 import { useIsRevisionOutdated } from '~/states/page';
 import { useIsRevisionOutdated } from '~/states/page';
 import { useShareLinkId } from '~/states/page/hooks';
 import { useShareLinkId } from '~/states/page/hooks';
-import { useCurrentPageYjsData } from '~/stores/yjs';
 
 
 import '@growi/remark-drawio/dist/style.css';
 import '@growi/remark-drawio/dist/style.css';
 import styles from './DrawioViewerWithEditButton.module.scss';
 import styles from './DrawioViewerWithEditButton.module.scss';
@@ -34,7 +34,7 @@ export const DrawioViewerWithEditButton = React.memo((props: DrawioViewerProps):
   const isSharedUser = useIsSharedUser();
   const isSharedUser = useIsSharedUser();
   const shareLinkId = useShareLinkId();
   const shareLinkId = useShareLinkId();
   const isRevisionOutdated = useIsRevisionOutdated();
   const isRevisionOutdated = useIsRevisionOutdated();
-  const { data: currentPageYjsData } = useCurrentPageYjsData();
+  const currentPageYjsData = useCurrentPageYjsData();
 
 
   const [isRendered, setRendered] = useState(false);
   const [isRendered, setRendered] = useState(false);
   const [mxfile, setMxfile] = useState('');
   const [mxfile, setMxfile] = useState('');

+ 3 - 2
apps/app/src/client/components/ReactMarkdownComponents/Header.tsx

@@ -8,9 +8,9 @@ import type { Element } from 'hast';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
+import { useCurrentPageYjsData, useCurrentPageYjsDataLoading } from '~/features/collaborative-editor/states';
 import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
 import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
 import { useShareLinkId } from '~/states/page/hooks';
 import { useShareLinkId } from '~/states/page/hooks';
-import { useCurrentPageYjsData } from '~/stores/yjs';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 
 
@@ -67,7 +67,8 @@ export const Header = (props: HeaderProps): JSX.Element => {
   const isReadOnlyUser = useIsReadOnlyUser();
   const isReadOnlyUser = useIsReadOnlyUser();
   const isSharedUser = useIsSharedUser();
   const isSharedUser = useIsSharedUser();
   const shareLinkId = useShareLinkId();
   const shareLinkId = useShareLinkId();
-  const { data: currentPageYjsData, isLoading: isLoadingCurrentPageYjsData } = useCurrentPageYjsData();
+  const currentPageYjsData = useCurrentPageYjsData();
+  const isLoadingCurrentPageYjsData = useCurrentPageYjsDataLoading();
 
 
   const router = useRouter();
   const router = useRouter();
 
 

+ 2 - 2
apps/app/src/client/components/ReactMarkdownComponents/TableWithEditButton.tsx

@@ -4,10 +4,10 @@ import type EventEmitter from 'events';
 
 
 import type { Element } from 'hast';
 import type { Element } from 'hast';
 
 
+import { useCurrentPageYjsData } from '~/features/collaborative-editor/states';
 import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
 import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
 import { useIsRevisionOutdated } from '~/states/page';
 import { useIsRevisionOutdated } from '~/states/page';
 import { useShareLinkId } from '~/states/page/hooks';
 import { useShareLinkId } from '~/states/page/hooks';
-import { useCurrentPageYjsData } from '~/stores/yjs';
 
 
 import styles from './TableWithEditButton.module.scss';
 import styles from './TableWithEditButton.module.scss';
 
 
@@ -30,7 +30,7 @@ const TableWithEditButtonNoMemorized = (props: TableWithEditButtonProps): JSX.El
   const isSharedUser = useIsSharedUser();
   const isSharedUser = useIsSharedUser();
   const shareLinkId = useShareLinkId();
   const shareLinkId = useShareLinkId();
   const isRevisionOutdated = useIsRevisionOutdated();
   const isRevisionOutdated = useIsRevisionOutdated();
-  const { data: currentPageYjsData } = useCurrentPageYjsData();
+  const currentPageYjsData = useCurrentPageYjsData();
 
 
   const bol = node.position?.start.line;
   const bol = node.position?.start.line;
   const eol = node.position?.end.line;
   const eol = node.position?.end.line;

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

@@ -1,25 +0,0 @@
-import { 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 { updateHasYdocsNewerThanLatestRevision, updateAwarenessStateSize } = useCurrentPageYjsData();
-
-  useEffect(() => {
-
-    if (socket == null) { return }
-
-    socket.on(SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated, updateHasYdocsNewerThanLatestRevision);
-    socket.on(SocketEventName.YjsAwarenessStateSizeUpdated, updateAwarenessStateSize);
-
-    return () => {
-      socket.off(SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated, updateHasYdocsNewerThanLatestRevision);
-      socket.off(SocketEventName.YjsAwarenessStateSizeUpdated, updateAwarenessStateSize);
-    };
-
-  }, [socket, updateAwarenessStateSize, updateHasYdocsNewerThanLatestRevision]);
-};

+ 72 - 0
apps/app/src/features/collaborative-editor/side-effects/index.ts

@@ -0,0 +1,72 @@
+import { useEffect } from 'react';
+import { useGlobalSocket } from '@growi/core/dist/swr';
+
+import { useCurrentPageYjsDataActions } from '~/features/collaborative-editor/states';
+import { SocketEventName } from '~/interfaces/websocket';
+import {
+  useCurrentPageData,
+  useCurrentPageId,
+  usePageNotFound,
+} from '~/states/page';
+
+export const useCurrentPageYjsDataAutoLoadEffect = (): void => {
+  const { fetchCurrentPageYjsData } = useCurrentPageYjsDataActions();
+  const pageId = useCurrentPageId();
+  const currentPage = useCurrentPageData();
+  const isNotFound = usePageNotFound();
+
+  // Optimized effects with minimal dependencies
+  useEffect(() => {
+    // Load YJS data only when revision changes and page exists
+    if (pageId && currentPage?.revision?._id && !isNotFound) {
+      fetchCurrentPageYjsData();
+    }
+  }, [currentPage?.revision?._id, fetchCurrentPageYjsData, isNotFound, pageId]);
+};
+
+export const useNewlyYjsDataSyncingEffect = (): void => {
+  const { data: socket } = useGlobalSocket();
+  const { updateHasYdocsNewerThanLatestRevision } =
+    useCurrentPageYjsDataActions();
+
+  useEffect(() => {
+    if (socket == null) {
+      return;
+    }
+
+    socket.on(
+      SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated,
+      updateHasYdocsNewerThanLatestRevision,
+    );
+
+    return () => {
+      socket.off(
+        SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated,
+        updateHasYdocsNewerThanLatestRevision,
+      );
+    };
+  }, [socket, updateHasYdocsNewerThanLatestRevision]);
+};
+
+export const useAwarenessSyncingEffect = (): void => {
+  const { data: socket } = useGlobalSocket();
+  const { updateAwarenessStateSize } = useCurrentPageYjsDataActions();
+
+  useEffect(() => {
+    if (socket == null) {
+      return;
+    }
+
+    socket.on(
+      SocketEventName.YjsAwarenessStateSizeUpdated,
+      updateAwarenessStateSize,
+    );
+
+    return () => {
+      socket.off(
+        SocketEventName.YjsAwarenessStateSizeUpdated,
+        updateAwarenessStateSize,
+      );
+    };
+  }, [socket, updateAwarenessStateSize]);
+};

+ 114 - 0
apps/app/src/features/collaborative-editor/states/current-page-yjs-data.ts

@@ -0,0 +1,114 @@
+import { useCallback } from 'react';
+import { atom, useAtomValue, useSetAtom } from 'jotai';
+
+import { apiv3Get } from '../../../client/util/apiv3-client';
+import type { CurrentPageYjsData } from '../../../interfaces/yjs';
+import { useCurrentPageId } from '../../../states/page';
+
+// ============================================================================
+// Atoms
+// ============================================================================
+
+const currentPageYjsDataAtom = atom<CurrentPageYjsData | undefined>(undefined);
+const currentPageYjsDataLoadingAtom = atom<boolean>(false);
+const currentPageYjsDataErrorAtom = atom<Error | undefined>(undefined);
+
+// ============================================================================
+// Read Hooks
+// ============================================================================
+
+/**
+ * Hook to get current page Yjs data
+ */
+export const useCurrentPageYjsData = (): CurrentPageYjsData | undefined => {
+  return useAtomValue(currentPageYjsDataAtom);
+};
+
+/**
+ * Hook to get loading state of current page Yjs data
+ */
+export const useCurrentPageYjsDataLoading = (): boolean => {
+  return useAtomValue(currentPageYjsDataLoadingAtom);
+};
+
+/**
+ * Hook to get error state of current page Yjs data
+ */
+export const useCurrentPageYjsDataError = (): Error | undefined => {
+  return useAtomValue(currentPageYjsDataErrorAtom);
+};
+
+// ============================================================================
+// Action Hooks
+// ============================================================================
+
+export type CurrentPageYjsDataActions = {
+  updateHasYdocsNewerThanLatestRevision: (
+    hasYdocsNewerThanLatestRevision: boolean,
+  ) => void;
+  updateAwarenessStateSize: (awarenessStateSize: number) => void;
+  fetchCurrentPageYjsData: () => Promise<CurrentPageYjsData>;
+};
+
+/**
+ * Actions hook for updating current page Yjs data
+ * Provides functions to update the state
+ */
+export const useCurrentPageYjsDataActions = (): CurrentPageYjsDataActions => {
+  const setData = useSetAtom(currentPageYjsDataAtom);
+  const setLoading = useSetAtom(currentPageYjsDataLoadingAtom);
+  const setError = useSetAtom(currentPageYjsDataErrorAtom);
+  const currentPageId = useCurrentPageId();
+
+  const updateHasYdocsNewerThanLatestRevision = useCallback(
+    (hasYdocsNewerThanLatestRevision: boolean) => {
+      setData((current) =>
+        current != null
+          ? { ...current, hasYdocsNewerThanLatestRevision }
+          : undefined,
+      );
+    },
+    [setData],
+  );
+
+  const updateAwarenessStateSize = useCallback(
+    (awarenessStateSize: number) => {
+      setData((current) =>
+        current != null ? { ...current, awarenessStateSize } : undefined,
+      );
+    },
+    [setData],
+  );
+
+  const fetchCurrentPageYjsData = useCallback(async () => {
+    if (currentPageId == null) {
+      throw new Error('Current page ID is not available');
+    }
+
+    setLoading(true);
+    setError(undefined);
+
+    try {
+      const endpoint = `/page/${currentPageId}/yjs-data`;
+      const result = await apiv3Get<{ yjsData: CurrentPageYjsData }>(endpoint);
+      const yjsData = result.data.yjsData;
+
+      setData(yjsData);
+      setLoading(false);
+
+      return yjsData;
+    } catch (error) {
+      const err =
+        error instanceof Error ? error : new Error('Failed to fetch Yjs data');
+      setError(err);
+      setLoading(false);
+      throw err;
+    }
+  }, [setData, setLoading, setError, currentPageId]);
+
+  return {
+    updateHasYdocsNewerThanLatestRevision,
+    updateAwarenessStateSize,
+    fetchCurrentPageYjsData,
+  };
+};

+ 6 - 0
apps/app/src/features/collaborative-editor/states/index.ts

@@ -0,0 +1,6 @@
+export {
+  type CurrentPageYjsDataActions,
+  useCurrentPageYjsData,
+  useCurrentPageYjsDataActions,
+  useCurrentPageYjsDataLoading,
+} from './current-page-yjs-data';

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

@@ -15,13 +15,12 @@ import { PageView } from '~/components/PageView/PageView';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { useEditorModeClassName } from '~/services/layout/use-editor-mode-class-name';
 import { useEditorModeClassName } from '~/services/layout/use-editor-mode-class-name';
 import {
 import {
-  useCurrentPageData, useCurrentPageId, useCurrentPagePath, usePageNotFound,
+  useCurrentPageData, useCurrentPagePath,
 } from '~/states/page';
 } from '~/states/page';
 import { useHydratePageAtoms } from '~/states/page/hydrate';
 import { useHydratePageAtoms } from '~/states/page/hydrate';
 import { useRendererConfig } from '~/states/server-configurations';
 import { useRendererConfig } from '~/states/server-configurations';
 import { useSetupGlobalSocket, useSetupGlobalSocketForPage } from '~/states/socket-io';
 import { useSetupGlobalSocket, useSetupGlobalSocketForPage } from '~/states/socket-io';
 import { useSetEditingMarkdown } from '~/states/ui/editor';
 import { useSetEditingMarkdown } from '~/states/ui/editor';
-import { useSWRMUTxCurrentPageYjsData } from '~/stores/yjs';
 
 
 import type { NextPageWithLayout } from '../_app.page';
 import type { NextPageWithLayout } from '../_app.page';
 import { useHydrateBasicLayoutConfigurationAtoms } from '../basic-layout-page/hydrate';
 import { useHydrateBasicLayoutConfigurationAtoms } from '../basic-layout-page/hydrate';
@@ -96,14 +95,10 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   });
   });
 
 
   const currentPage = useCurrentPageData();
   const currentPage = useCurrentPageData();
-  const pageId = useCurrentPageId();
   const currentPagePath = useCurrentPagePath();
   const currentPagePath = useCurrentPagePath();
-  const isNotFound = usePageNotFound();
   const rendererConfig = useRendererConfig();
   const rendererConfig = useRendererConfig();
   const setEditingMarkdown = useSetEditingMarkdown();
   const setEditingMarkdown = useSetEditingMarkdown();
 
 
-  const { trigger: mutateCurrentPageYjsDataFromApi } = useSWRMUTxCurrentPageYjsData();
-
   // setup socket.io
   // setup socket.io
   useSetupGlobalSocket();
   useSetupGlobalSocket();
   useSetupGlobalSocketForPage();
   useSetupGlobalSocketForPage();
@@ -115,14 +110,6 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   // If initial props and skipSSR, fetch page data on client-side
   // If initial props and skipSSR, fetch page data on client-side
   useInitialCSRFetch(isInitialProps(props) && props.skipSSR);
   useInitialCSRFetch(isInitialProps(props) && props.skipSSR);
 
 
-  // Optimized effects with minimal dependencies
-  useEffect(() => {
-    // Load YJS data only when revision changes and page exists
-    if (pageId && currentPage?.revision?._id && !isNotFound) {
-      mutateCurrentPageYjsDataFromApi();
-    }
-  }, [currentPage?.revision?._id, mutateCurrentPageYjsDataFromApi, isNotFound, pageId]);
-
   useEffect(() => {
   useEffect(() => {
     // Initialize editing markdown only when page path changes
     // Initialize editing markdown only when page path changes
     if (currentPagePath) {
     if (currentPagePath) {

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

@@ -1,49 +0,0 @@
-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 '../states/page';
-
-type CurrentPageYjsDataUtils = {
-  updateHasYdocsNewerThanLatestRevision(hasYdocsNewerThanLatestRevision: boolean): void
-  updateAwarenessStateSize(awarenessStateSize: number): void
-}
-
-export const useCurrentPageYjsData = (): SWRResponse<CurrentPageYjsData, Error> & CurrentPageYjsDataUtils => {
-  const currentPageId = useCurrentPageId();
-
-  const key = currentPageId != null
-    ? `/page/${currentPageId}/yjs-data`
-    : null;
-
-  const swrResponse = useSWRStatic<CurrentPageYjsData, Error>(key, undefined);
-
-  const updateHasYdocsNewerThanLatestRevision = useCallback((hasYdocsNewerThanLatestRevision: boolean) => {
-    swrResponse.mutate({ ...swrResponse.data, hasYdocsNewerThanLatestRevision });
-  }, [swrResponse]);
-
-  const updateAwarenessStateSize = useCallback((awarenessStateSize: number) => {
-    swrResponse.mutate({ ...swrResponse.data, awarenessStateSize });
-  }, [swrResponse]);
-
-  return Object.assign(swrResponse, { updateHasYdocsNewerThanLatestRevision, updateAwarenessStateSize });
-};
-
-export const useSWRMUTxCurrentPageYjsData = (): SWRMutationResponse<CurrentPageYjsData, Error> => {
-  const currentPageId = useCurrentPageId();
-
-  const key = currentPageId != null
-    ? `/page/${currentPageId}/yjs-data`
-    : null;
-
-  return useSWRMutation(
-    key,
-    endpoint => apiv3Get<{ yjsData: CurrentPageYjsData }>(endpoint).then(result => result.data.yjsData),
-    { populateCache: true, revalidate: false },
-  );
-};