Shun Miyazawa 2 yıl önce
ebeveyn
işleme
af535f3e7c

+ 138 - 1
apps/app/src/client/services/update-page/conflict.tsx

@@ -1,7 +1,43 @@
+import { useCallback, useEffect } from 'react';
+
+import { Origin } from '@growi/core';
 import type { ErrorV3 } from '@growi/core/dist/models';
+import { useGlobalSocket } from '@growi/core/dist/swr';
+import { GlobalCodeMirrorEditorKey, useCodeMirrorEditorIsolated } from '@growi/editor';
+import { useTranslation } from 'react-i18next';
 
+import { toastSuccess } from '~/client/util/toastr';
+import type { Save, SaveOptions } from '~/components/PageEditor/PageEditor';
 import { PageUpdateErrorCode } from '~/interfaces/apiv3';
-import { type RemoteRevisionData } from '~/stores/remote-latest-page';
+import { SocketEventName } from '~/interfaces/websocket';
+import { usePageStatusAlert } from '~/stores/alert';
+import { useConflictDiffModal } from '~/stores/modal';
+import { useCurrentPageId, useSWRxCurrentPage } from '~/stores/page';
+import { type RemoteRevisionData, useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+
+import { useUpdateStateAfterSave } from '../page-operation';
+
+export type ConflictHandler = (
+  remoteRevisionData: RemoteRevisionData,
+  requestMarkdown: string,
+  save: Save,
+  saveOptions?: SaveOptions,
+) => void;
+
+type GenerateResolveConflicthandler = () => (
+  revisionId: string,
+  save: Save,
+  saveOptions?: SaveOptions,
+  onConflict?: () => void
+) => (newMarkdown: string) => Promise<void>
+
+type ConflictResolver = () => (
+  remoteRevisionData: RemoteRevisionData,
+  requestMarkdown: string,
+  save: Save,
+  saveOptions?: SaveOptions,
+) => void;
 
 export const extractRemoteRevisionDataFromErrorObj = (errors: Array<ErrorV3>): RemoteRevisionData | undefined => {
   for (const error of errors) {
@@ -20,3 +56,104 @@ export const extractRemoteRevisionDataFromErrorObj = (errors: Array<ErrorV3>): R
     }
   }
 };
+
+export const useGenerateResolveConflictHandler: GenerateResolveConflicthandler = () => {
+  const { t } = useTranslation();
+
+  const { data: pageId } = useCurrentPageId();
+  const { close: closePageStatusAlert } = usePageStatusAlert();
+  const { close: closeConflictDiffModal } = useConflictDiffModal();
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
+  const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
+
+  return useCallback((revisionId, save, saveOptions, onConflict) => {
+    return async(newMarkdown) => {
+      const page = await save(revisionId, newMarkdown, saveOptions, onConflict);
+      if (page == null) {
+        return;
+      }
+
+      // Reflect conflict resolution results in CodeMirrorEditor
+      codeMirrorEditor?.initDoc(newMarkdown);
+
+      closePageStatusAlert();
+      closeConflictDiffModal();
+
+      toastSuccess(t('toaster.save_succeeded'));
+      updateStateAfterSave?.();
+    };
+  }, [closeConflictDiffModal, closePageStatusAlert, codeMirrorEditor, t, updateStateAfterSave]);
+};
+
+export const useConflictResolver: ConflictResolver = () => {
+  const { open: openPageStatusAlert } = usePageStatusAlert();
+  const { open: openConflictDiffModal } = useConflictDiffModal();
+  const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
+  const generateResolveConflictHandler = useGenerateResolveConflictHandler();
+
+  return useCallback((remoteRevidsionData, requestMarkdown, save, saveOptions) => {
+    const onConflict = () => {
+      const resolveConflictHandler = generateResolveConflictHandler(remoteRevidsionData.remoteRevisionId, save, saveOptions, onConflict);
+      openPageStatusAlert({ onResolveConflict: () => openConflictDiffModal(requestMarkdown, resolveConflictHandler) });
+      setRemoteLatestPageData(remoteRevidsionData);
+    };
+
+    onConflict();
+  }, [generateResolveConflictHandler, openConflictDiffModal, openPageStatusAlert, setRemoteLatestPageData]);
+};
+
+export const useConflictEffect = (): void => {
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { close: closePageStatusAlert } = usePageStatusAlert();
+  const { close: closeConflictDiffModal } = useConflictDiffModal();
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
+  const { open: openPageStatusAlert } = usePageStatusAlert();
+  const { open: openConflictDiffModal } = useConflictDiffModal();
+  const { data: socket } = useGlobalSocket();
+  const { data: editorMode } = useEditorMode();
+
+  const onConflictHandler = useCallback(() => {
+    const onResolveConflict = () => {
+      const resolveConflictHandler = (newMarkdown: string) => {
+        codeMirrorEditor?.initDoc(newMarkdown);
+        closeConflictDiffModal();
+        closePageStatusAlert();
+      };
+
+      const markdown = codeMirrorEditor?.getDoc();
+      openConflictDiffModal(markdown ?? '', resolveConflictHandler);
+    };
+
+    openPageStatusAlert({ onResolveConflict });
+  }, [closeConflictDiffModal, closePageStatusAlert, codeMirrorEditor, openConflictDiffModal, openPageStatusAlert]);
+
+  const updateRemotePageDataHandler = useCallback((data) => {
+    const { s2cMessagePageUpdated } = data;
+
+    const remoteRevisionId = s2cMessagePageUpdated.revisionId;
+    const remoteRevisionOrigin = s2cMessagePageUpdated.revisionOrigin;
+    const currentRevisionId = currentPage?.revision?._id;
+    const isRevisionOutdated = (currentRevisionId != null || remoteRevisionId != null) && currentRevisionId !== remoteRevisionId;
+
+    // !!CAUTION!! Timing of calling openPageStatusAlert may clash with client/services/side-effects/page-updated.ts
+    if (isRevisionOutdated && editorMode === EditorMode.Editor && (remoteRevisionOrigin === Origin.View || remoteRevisionOrigin === undefined)) {
+      onConflictHandler();
+    }
+
+    // Clear cache
+    if (!isRevisionOutdated) {
+      closePageStatusAlert();
+    }
+  }, [closePageStatusAlert, currentPage?.revision?._id, editorMode, onConflictHandler]);
+
+  useEffect(() => {
+    if (socket == null) { return }
+
+    socket.on(SocketEventName.PageUpdated, updateRemotePageDataHandler);
+
+    return () => {
+      socket.off(SocketEventName.PageUpdated, updateRemotePageDataHandler);
+    };
+
+  }, [socket, updateRemotePageDataHandler]);
+};

+ 13 - 96
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -8,7 +8,6 @@ import type EventEmitter from 'events';
 import nodePath from 'path';
 
 import { type IPageHasId, Origin } from '@growi/core';
-import { useGlobalSocket } from '@growi/core/dist/swr';
 import { pathUtils } from '@growi/core/dist/utils';
 import {
   CodeMirrorEditorMain, GlobalCodeMirrorEditorKey,
@@ -21,11 +20,9 @@ import { throttle, debounce } from 'throttle-debounce';
 
 import { useShouldExpandContent } from '~/client/services/layout';
 import { useUpdateStateAfterSave } from '~/client/services/page-operation';
-import { updatePage, extractRemoteRevisionDataFromErrorObj } from '~/client/services/update-page';
+import { updatePage, extractRemoteRevisionDataFromErrorObj, useConflictResolver, useConflictEffect, type ConflictHandler } from '~/client/services/update-page';
 import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess, toastWarning } from '~/client/util/toastr';
-import { SocketEventName } from '~/interfaces/websocket';
-import { usePageStatusAlert } from '~/stores/alert';
 import {
   useDefaultIndentSize, useCurrentUser,
   useCurrentPathname, useIsEnabledAttachTitleHeader,
@@ -38,13 +35,11 @@ import {
   useEditingMarkdown,
   useWaitingSaveProcessing,
 } from '~/stores/editor';
-import { useConflictDiffModal } from '~/stores/modal';
 import {
   useCurrentPagePath, useSWRxCurrentPage, useCurrentPageId, useIsNotFound, useTemplateBodyData,
 } from '~/stores/page';
 import { mutatePageTree } from '~/stores/page-listing';
 import type { RemoteRevisionData } from '~/stores/remote-latest-page';
-import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import { usePreviewOptions } from '~/stores/renderer';
 import {
   EditorMode,
@@ -74,22 +69,15 @@ declare global {
 let isOriginOfScrollSyncEditor = false;
 let isOriginOfScrollSyncPreview = false;
 
-type SaveOptions = {
+export type SaveOptions = {
   slackChannels: string,
   overwriteScopesOfDescendants?: boolean
 }
-
-type ConflictHandler = (
-  conflictData: RemoteRevisionData,
-  newMarkdown: string,
-  saveOptions?: SaveOptions
-) => void;
-
-type Save = (
+export type Save = (
   revisionId?: string,
-  markdown?: string,
+  requestMarkdown?: string,
   opts?: SaveOptions,
-  onConflict?: ConflictHandler,
+  onConflict?: ConflictHandler
 ) => Promise<IPageHasId | null>
 
 type Props = {
@@ -119,13 +107,10 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { data: defaultIndentSize } = useDefaultIndentSize();
   const { data: acceptedUploadFileType } = useAcceptedUploadFileType();
-  const { open: openConflictDiffModal, close: closeConflictDiffModal } = useConflictDiffModal();
-  const { open: openPageStatusAlert, close: closePageStatusAlert } = usePageStatusAlert();
   const { data: editorSettings } = useEditorSettings();
-  const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
   const { data: user } = useCurrentUser();
   const { onEditorsUpdated } = useEditingUsers();
-  const { data: socket } = useGlobalSocket();
+  const onConflict = useConflictResolver();
 
   const { data: rendererOptions } = usePreviewOptions();
 
@@ -135,6 +120,8 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   const updateStateAfterSave = useUpdateStateAfterSave(pageId, { supressEditingMarkdownMutation: true });
 
+  useConflictEffect();
+
   const { resolvedTheme } = useNextThemes();
   mutateResolvedTheme({ themeData: resolvedTheme });
 
@@ -176,46 +163,6 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
 
-  const onConflictHandlerEffect = useCallback(() => {
-    const resolveConflictHandler = (newMarkdown: string) => {
-      codeMirrorEditor?.initDoc(newMarkdown);
-      closeConflictDiffModal();
-      closePageStatusAlert();
-    };
-
-    const markdown = codeMirrorEditor?.getDoc();
-    openConflictDiffModal(markdown ?? '', resolveConflictHandler);
-  }, [closePageStatusAlert, closeConflictDiffModal, codeMirrorEditor, openConflictDiffModal]);
-
-  useEffect(() => {
-    const updateRemotePageDataHandler = (data) => {
-      const { s2cMessagePageUpdated } = data;
-      const remoteRevisionId = s2cMessagePageUpdated.revisionId;
-      const remoteRevisionOrigin = s2cMessagePageUpdated.revisionOrigin;
-      const isRevisionOutdated = currentRevisionId !== remoteRevisionId;
-
-      // !!CAUTION!! Timing of calling openPageStatusAlert may clash with client/services/side-effects/page-updated.ts
-      if (isRevisionOutdated && editorMode === EditorMode.Editor && (remoteRevisionOrigin === Origin.View || remoteRevisionOrigin === undefined)) {
-        openPageStatusAlert({ onResolveConflict: onConflictHandlerEffect });
-      }
-
-      // Clear cache
-      if (!isRevisionOutdated) {
-        closePageStatusAlert();
-      }
-    };
-
-    if (socket == null) { return }
-
-    socket.on(SocketEventName.PageUpdated, updateRemotePageDataHandler);
-
-    return () => {
-      socket.off(SocketEventName.PageUpdated, updateRemotePageDataHandler);
-    };
-
-  }, [closePageStatusAlert, currentRevisionId, editorMode, onConflictHandlerEffect, openPageStatusAlert, socket]);
-
-
   const save: Save = useCallback(async(revisionId, markdown, opts, onConflict) => {
     if (pageId == null || grantData == null) {
       logger.error('Some materials to save are invalid', {
@@ -249,7 +196,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
       const remoteRevisionData = extractRemoteRevisionDataFromErrorObj(error);
       if (remoteRevisionData != null) {
-        onConflict?.(remoteRevisionData, markdown ?? '', opts);
+        onConflict?.(remoteRevisionData, markdown ?? '', save, opts);
         toastWarning(t('modal_resolve_conflict.conflicts_with_new_body_on_server_side'));
         return null;
       }
@@ -262,59 +209,29 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     }
   }, [pageId, grantData, mutateWaitingSaveProcessing, t]);
 
-  const generateResolveConflictHandler = useCallback((revisionId: string, saveOptions?: SaveOptions, onConflict?: ConflictHandler) => {
-    return async(newMarkdown: string) => {
-      const page = await save(revisionId, newMarkdown, saveOptions, onConflict);
-      if (page == null) {
-        return;
-      }
-
-      // Reflect conflict resolution results in CodeMirrorEditor
-      codeMirrorEditor?.initDoc(newMarkdown);
-
-      closePageStatusAlert();
-      closeConflictDiffModal();
-
-      toastSuccess(t('toaster.save_succeeded'));
-      updateStateAfterSave?.();
-    };
-  }, [save, codeMirrorEditor, closePageStatusAlert, closeConflictDiffModal, t, updateStateAfterSave]);
-
-  const onConflictHandler: ConflictHandler = useCallback((remoteRevidsionData, newMarkdown, saveOptions) => {
-    setRemoteLatestPageData(remoteRevidsionData);
-
-    const resolveConflictHandler = generateResolveConflictHandler(remoteRevidsionData.remoteRevisionId, saveOptions, onConflictHandler);
-
-    const conflictHandler = () => {
-      openConflictDiffModal(newMarkdown, resolveConflictHandler);
-    };
-
-    openPageStatusAlert({ onResolveConflict: conflictHandler });
-  }, [setRemoteLatestPageData, generateResolveConflictHandler, openPageStatusAlert, openConflictDiffModal]);
-
   const saveAndReturnToViewHandler = useCallback(async(opts: SaveOptions) => {
     const markdown = codeMirrorEditor?.getDoc();
     const revisionId = isRevisionIdRequiredForPageUpdate ? currentRevisionId : undefined;
-    const page = await save(revisionId, markdown, opts, onConflictHandler);
+    const page = await save(revisionId, markdown, opts, onConflict);
     if (page == null) {
       return;
     }
 
     mutateEditorMode(EditorMode.View);
     updateStateAfterSave?.();
-  }, [codeMirrorEditor, currentRevisionId, isRevisionIdRequiredForPageUpdate, mutateEditorMode, onConflictHandler, save, updateStateAfterSave]);
+  }, [codeMirrorEditor, currentRevisionId, isRevisionIdRequiredForPageUpdate, mutateEditorMode, save, updateStateAfterSave]);
 
   const saveWithShortcut = useCallback(async() => {
     const markdown = codeMirrorEditor?.getDoc();
     const revisionId = isRevisionIdRequiredForPageUpdate ? currentRevisionId : undefined;
-    const page = await save(revisionId, markdown, undefined, onConflictHandler);
+    const page = await save(revisionId, markdown, undefined, onConflict);
     if (page == null) {
       return;
     }
 
     toastSuccess(t('toaster.save_succeeded'));
     updateStateAfterSave?.();
-  }, [codeMirrorEditor, currentRevisionId, isRevisionIdRequiredForPageUpdate, onConflictHandler, save, t, updateStateAfterSave]);
+  }, [codeMirrorEditor, currentRevisionId, isRevisionIdRequiredForPageUpdate, save, t, updateStateAfterSave]);
 
 
   // the upload event handler