Просмотр исходного кода

Merge pull request #8253 from weseek/feat/132901-refactor-yjs-editor

feat: Refactor yjs editor
Yuki Takei 2 лет назад
Родитель
Сommit
75309888ea

+ 2 - 1
apps/app/src/client/services/side-effects/page-updated.ts

@@ -1,9 +1,10 @@
 import { useCallback, useEffect } from 'react';
 
+import { useGlobalSocket } from '@growi/core/dist/swr';
+
 import { SocketEventName } from '~/interfaces/websocket';
 import { useCurrentPageId } from '~/stores/page';
 import { useSetRemoteLatestPageData, type RemoteRevisionData } from '~/stores/remote-latest-page';
-import { useGlobalSocket } from '~/stores/websocket';
 
 export const usePageUpdatedEffect = (): void => {
 

+ 1 - 1
apps/app/src/components/ItemsTree/ItemsTree.tsx

@@ -5,6 +5,7 @@ import React, {
 import path from 'path';
 
 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';
@@ -22,7 +23,6 @@ import {
 } from '~/stores/page-listing';
 import { mutateSearching } from '~/stores/search';
 import { usePageTreeDescCountMap, useSidebarScrollerRef } from '~/stores/ui';
-import { useGlobalSocket } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 
 import { ItemNode, SimpleItemProps } from '../TreeItem';

+ 4 - 5
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -6,6 +6,7 @@ import EventEmitter from 'events';
 import nodePath from 'path';
 
 import type { IPageHasId } from '@growi/core';
+import { useGlobalSocket } from '@growi/core/dist/swr';
 import { pathUtils } from '@growi/core/dist/utils';
 import {
   CodeMirrorEditorMain, GlobalCodeMirrorEditorKey, AcceptedUploadFileType,
@@ -50,7 +51,6 @@ import {
   useEditorMode, useSelectedGrant,
 } from '~/stores/ui';
 import { useNextThemes } from '~/stores/use-next-themes';
-import { useGlobalSocket } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 
 
@@ -580,13 +580,12 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             onChange={markdownChangedHandler}
             onSave={saveWithShortcut}
             onUpload={uploadHandler}
+            acceptedFileType={acceptedFileType}
             indentSize={currentIndentSize ?? defaultIndentSize}
-            pageId={pageId}
             userName={user?.name}
-            socket={socket}
+            pageId={pageId ?? undefined}
             initialValue={initialValue}
-            setMarkdownToPreview={setMarkdownToPreview}
-            acceptedFileType={acceptedFileType}
+            onOpenEditor={markdown => setMarkdownToPreview(markdown)}
           />
         </div>
         <div className="page-editor-preview-container flex-expand-vert d-none d-lg-flex">

+ 1 - 1
apps/app/src/components/PrivateLegacyPages.tsx

@@ -2,6 +2,7 @@ import React, {
   useCallback, useMemo, useRef, useState, useEffect,
 } from 'react';
 
+import { useGlobalSocket } from '@growi/core/dist/swr';
 import { useTranslation } from 'next-i18next';
 import {
   UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem, Modal, ModalHeader, ModalBody, ModalFooter,
@@ -22,7 +23,6 @@ import { mutatePageTree, useSWRxV5MigrationStatus } from '~/stores/page-listing'
 import {
   useSWRxSearch,
 } from '~/stores/search';
-import { useGlobalSocket } from '~/stores/websocket';
 
 import { MenuItemType } from './Common/Dropdown/PageItemControl';
 import PaginationWrapper from './PaginationWrapper';

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

@@ -44,10 +44,6 @@ export const SocketEventName = {
   PageCreated: 'page:create',
   PageUpdated: 'page:update',
   PageDeleted: 'page:delete',
-
-  // YDoc
-  YDocSync: 'ydoc:sync',
-  YDocSyncError: 'ydoc:sync:error',
 } as const;
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 

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

@@ -1,11 +1,11 @@
+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 YjsConnectionManager from './yjsConnectionManager';
+import YjsConnectionManager from './yjs-connection-manager';
 
 const expressSession = require('express-session');
 const passport = require('passport');
@@ -161,13 +161,13 @@ class SocketIoService {
 
   setupYjsConnection() {
     this.io.on('connection', (socket) => {
-      socket.on(SocketEventName.YDocSync, async({ pageId, initialValue }) => {
+      socket.on(GlobalSocketEventName.YDocSync, async({ pageId, initialValue }) => {
         try {
           await this.yjsConnectionManager.handleYDocSync(pageId, initialValue);
         }
         catch (error) {
           logger.warn(error.message);
-          socket.emit(SocketEventName.YDocSyncError, 'An error occurred during YDoc synchronization.');
+          socket.emit(GlobalSocketEventName.YDocSyncError, 'An error occurred during YDoc synchronization.');
         }
       });
     });

+ 0 - 0
apps/app/src/server/service/yjsConnectionManager.ts → apps/app/src/server/service/yjs-connection-manager.ts


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

@@ -1,5 +1,6 @@
 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';
 
@@ -9,9 +10,6 @@ import { useStaticSWR } from './use-static-swr';
 
 const logger = loggerFactory('growi:stores:ui');
 
-export const GLOBAL_SOCKET_NS = '/';
-export const GLOBAL_SOCKET_KEY = 'globalSocket';
-
 export const GLOBAL_ADMIN_SOCKET_NS = '/admin';
 export const GLOBAL_ADMIN_SOCKET_KEY = 'globalAdminSocket';
 
@@ -40,10 +38,6 @@ export const useSetupGlobalSocket = (): void => {
   }, [mutate]);
 };
 
-export const useGlobalSocket = (): SWRResponse<Socket, Error> => {
-  return useStaticSWR(GLOBAL_SOCKET_KEY);
-};
-
 // comment out for porduction build error: https://github.com/weseek/growi/pull/7131
 /*
  * Global Admin Socket

+ 1 - 0
packages/core/src/interfaces/index.ts

@@ -13,3 +13,4 @@ export * from './subscription';
 export * from './tag';
 export * from './user';
 export * from './vite';
+export * from './websocket';

+ 6 - 0
packages/core/src/interfaces/websocket.ts

@@ -0,0 +1,6 @@
+export const GlobalSocketEventName = {
+  // YDoc
+  YDocSync: 'ydoc:sync',
+  YDocSyncError: 'ydoc:sync:error',
+} as const;
+export type GlobalSocketEventName = typeof GlobalSocketEventName[keyof typeof GlobalSocketEventName];

+ 1 - 0
packages/core/src/swr/index.ts

@@ -1,2 +1,3 @@
 export * from './use-swr-static';
 export * from './with-utils';
+export * from './use-global-socket';

+ 11 - 0
packages/core/src/swr/use-global-socket.ts

@@ -0,0 +1,11 @@
+import type { Socket } from 'socket.io-client';
+import type { SWRResponse } from 'swr';
+
+import { useSWRStatic } from './use-swr-static';
+
+export const GLOBAL_SOCKET_NS = '/';
+export const GLOBAL_SOCKET_KEY = 'globalSocket';
+
+export const useGlobalSocket = (): SWRResponse<Socket, Error> => {
+  return useSWRStatic(GLOBAL_SOCKET_KEY);
+};

+ 10 - 122
packages/editor/src/components/CodeMirrorEditorMain.tsx

@@ -1,25 +1,12 @@
-import { useEffect, useState } from 'react';
+import { useEffect } from 'react';
 
 import type { Extension } from '@codemirror/state';
 import { keymap, scrollPastEnd } from '@codemirror/view';
-import type { Nullable } from '@growi/core';
-// TODO: import socket.io-client types wihtout lint error
-// import type { Socket, DefaultEventsMap } from 'socket.io-client';
-// eslint-disable-next-line @typescript-eslint/ban-ts-comment
-// @ts-ignore
-import { yCollab } from 'y-codemirror.next';
-import { SocketIOProvider } from 'y-socket.io';
-import * as Y from 'yjs';
-
-import { GlobalCodeMirrorEditorKey, AcceptedUploadFileType, userColor } from '../consts';
-import { useCodeMirrorEditorIsolated } from '../stores';
 
-import { CodeMirrorEditor } from '.';
+import { GlobalCodeMirrorEditorKey, AcceptedUploadFileType } from '../consts';
+import { useCodeMirrorEditorIsolated, useCollaborativeEditorMode } from '../stores';
 
-// TODO: use SocketEventName
-// import { SocketEventName } from '~/interfaces/websocket';
-// TODO: import { GLOBAL_SOCKET_NS } from '~/stores/websocket';
-const GLOBAL_SOCKET_NS = '/';
+import { CodeMirrorEditor } from '.';
 
 const additionalExtensions: Extension[] = [
   scrollPastEnd(),
@@ -31,121 +18,22 @@ type Props = {
   onUpload?: (files: File[]) => void,
   acceptedFileType?: AcceptedUploadFileType,
   indentSize?: number,
-  pageId: Nullable<string>,
   userName?: string,
-  socket?: any, // Socket<DefaultEventsMap, DefaultEventsMap>,
-  initialValue: string,
-  setMarkdownToPreview: React.Dispatch<React.SetStateAction<string>>,
+  pageId?: string,
+  initialValue?: string,
+  onOpenEditor?: (markdown: string) => void,
 }
 
 export const CodeMirrorEditorMain = (props: Props): JSX.Element => {
   const {
-    onSave, onChange, onUpload, acceptedFileType, indentSize, pageId, userName, initialValue, socket, setMarkdownToPreview,
+    onSave, onChange, onUpload, acceptedFileType, indentSize, userName, pageId, initialValue, onOpenEditor,
   } = props;
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
-  const [ydoc, setYdoc] = useState<Y.Doc | null>(null);
-  const [provider, setProvider] = useState<SocketIOProvider | null>(null);
-  const [cPageId, setCPageId] = useState(pageId);
-
-  const acceptedFileTypeNoOpt = acceptedFileType ?? AcceptedUploadFileType.NONE;
-
-  // cleanup ydoc and socketIOProvider
-  useEffect(() => {
-    if (cPageId === pageId) {
-      return;
-    }
-    if (!provider || !ydoc || socket == null) {
-      return;
-    }
-
-    ydoc.destroy();
-    setYdoc(null);
-
-    provider.destroy();
-    provider.disconnect();
-    setProvider(null);
-
-    // TODO: catch ydoc:sync:error
-    // TODO: use SocketEventName
-    socket.off('ydoc:sync');
-
-    setCPageId(pageId);
-  }, [cPageId, pageId, provider, socket, ydoc]);
-
-  // setup ydoc
-  useEffect(() => {
-    if (ydoc != null) {
-      return;
-    }
 
-    const _ydoc = new Y.Doc();
-    setYdoc(_ydoc);
-  }, [initialValue, ydoc]);
+  useCollaborativeEditorMode(userName, pageId, initialValue, onOpenEditor, codeMirrorEditor);
 
-  // setup socketIOProvider
-  useEffect(() => {
-    if (ydoc == null || provider != null || socket == null) {
-      return;
-    }
-
-    const socketIOProvider = new SocketIOProvider(
-      GLOBAL_SOCKET_NS,
-      `yjs/${pageId}`,
-      ydoc,
-      { autoConnect: true },
-    );
-
-    socketIOProvider.awareness.setLocalStateField('user', {
-      name: userName ? `${userName}` : `Guest User ${Math.floor(Math.random() * 100)}`,
-      color: userColor.color,
-      colorLight: userColor.light,
-    });
-
-    socketIOProvider.on('sync', (isSync: boolean) => {
-      if (isSync) {
-        // TODO: use SocketEventName
-        socket.emit('ydoc:sync', { pageId, initialValue });
-      }
-    });
-
-    // TODO: delete this code
-    socketIOProvider.on('status', ({ status: _status }: { status: string }) => {
-      if (_status) console.log(_status);
-    });
-
-    setProvider(socketIOProvider);
-  }, [initialValue, pageId, provider, socket, userName, ydoc]);
-
-  // attach YDoc to CodeMirror
-  useEffect(() => {
-    if (ydoc == null || provider == null) {
-      return;
-    }
-
-    const ytext = ydoc.getText('codemirror');
-    const undoManager = new Y.UndoManager(ytext);
-
-    const cleanup = codeMirrorEditor?.appendExtensions?.([
-      yCollab(ytext, provider.awareness, { undoManager }),
-    ]);
-
-    return cleanup;
-  }, [codeMirrorEditor, provider, setMarkdownToPreview, ydoc]);
-
-
-  // initialize markdown and preview
-  useEffect(() => {
-    if (ydoc == null) {
-      return;
-    }
-
-    const ytext = ydoc.getText('codemirror');
-    codeMirrorEditor?.initDoc(ytext.toString());
-    setMarkdownToPreview(ytext.toString());
-    // TODO: Check the reproduction conditions that made this code necessary and confirm reproduction
-    // mutateIsEnabledUnsavedWarning(false);
-  }, [codeMirrorEditor, initialValue, pageId, setMarkdownToPreview, socket, ydoc]);
+  const acceptedFileTypeNoOpt = acceptedFileType ?? AcceptedUploadFileType.NONE;
 
   // setup additional extensions
   useEffect(() => {

+ 0 - 3
packages/editor/src/components/playground/Playground.tsx

@@ -60,10 +60,7 @@ export const Playground = (): JSX.Element => {
             onChange={setMarkdownToPreview}
             onUpload={uploadHandler}
             indentSize={4}
-            setMarkdownToPreview={setMarkdownToPreview}
-            initialValue={initialValue}
             acceptedFileType={AcceptedUploadFileType.ALL}
-            pageId={undefined}
           />
         </div>
         <div className="flex-expand-vert d-none d-lg-flex bg-light text-dark border-start border-dark-subtle p-3">

+ 1 - 0
packages/editor/src/stores/index.ts

@@ -1,2 +1,3 @@
 export * from './codemirror-editor';
 export * from './use-resolved-theme';
+export * from './use-collaborative-editor-mode';

+ 116 - 0
packages/editor/src/stores/use-collaborative-editor-mode.ts

@@ -0,0 +1,116 @@
+import { useEffect, useState } from 'react';
+
+import { GlobalSocketEventName } from '@growi/core/dist/interfaces';
+import { useGlobalSocket, GLOBAL_SOCKET_NS } from '@growi/core/dist/swr';
+// see: https://github.com/yjs/y-codemirror.next#example
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore
+import { yCollab } from 'y-codemirror.next';
+import { SocketIOProvider } from 'y-socket.io';
+import * as Y from 'yjs';
+
+import { userColor } from '../consts';
+import { UseCodeMirrorEditor } from '../services';
+
+export const useCollaborativeEditorMode = (
+    userName?: string,
+    pageId?: string,
+    initialValue?: string,
+    onOpenEditor?: (markdown: string) => void,
+    codeMirrorEditor?: UseCodeMirrorEditor,
+): void => {
+  const [ydoc, setYdoc] = useState<Y.Doc | null>(null);
+  const [provider, setProvider] = useState<SocketIOProvider | null>(null);
+  const [isInit, setIsInit] = useState(false);
+  const [cPageId, setCPageId] = useState(pageId);
+
+  const { data: socket } = useGlobalSocket();
+
+  const cleanupYDocAndProvider = () => {
+    if (cPageId === pageId) {
+      return;
+    }
+
+    ydoc?.destroy();
+    setYdoc(null);
+
+    provider?.disconnect();
+    provider?.destroy();
+    setProvider(null);
+
+    // TODO: catch ydoc:sync:error GlobalSocketEventName.YDocSyncError
+    socket.off(GlobalSocketEventName.YDocSync);
+
+    setIsInit(false);
+    setCPageId(pageId);
+  };
+
+  const setupYDoc = () => {
+    if (ydoc != null) {
+      return;
+    }
+
+    const _ydoc = new Y.Doc();
+    setYdoc(_ydoc);
+  };
+
+  const setupProvider = () => {
+    if (provider != null || ydoc == null || socket == null) {
+      return;
+    }
+
+    const socketIOProvider = new SocketIOProvider(
+      GLOBAL_SOCKET_NS,
+      `yjs/${pageId}`,
+      ydoc,
+      { autoConnect: true },
+    );
+
+    socketIOProvider.awareness.setLocalStateField('user', {
+      name: userName ? `${userName}` : `Guest User ${Math.floor(Math.random() * 100)}`,
+      color: userColor.color,
+      colorLight: userColor.light,
+    });
+
+    socketIOProvider.on('sync', (isSync: boolean) => {
+      if (isSync) {
+        socket.emit(GlobalSocketEventName.YDocSync, { pageId, initialValue });
+      }
+    });
+
+    setProvider(socketIOProvider);
+  };
+
+  const attachYDocExtensionsToCodeMirror = () => {
+    if (ydoc == null || provider == null) {
+      return;
+    }
+
+    const ytext = ydoc.getText('codemirror');
+    const undoManager = new Y.UndoManager(ytext);
+
+    const cleanup = codeMirrorEditor?.appendExtensions?.([
+      yCollab(ytext, provider.awareness, { undoManager }),
+    ]);
+
+    return cleanup;
+  };
+
+  const initializeEditor = () => {
+    if (ydoc == null || onOpenEditor == null || isInit === true) {
+      return;
+    }
+
+    const ytext = ydoc.getText('codemirror');
+    codeMirrorEditor?.initDoc(ytext.toString());
+    onOpenEditor(ytext.toString());
+
+    setIsInit(true);
+  };
+
+  useEffect(cleanupYDocAndProvider, [cPageId, pageId, provider, socket, ydoc]);
+  useEffect(setupYDoc, [ydoc]);
+  useEffect(setupProvider, [initialValue, pageId, provider, socket, userName, ydoc]);
+  useEffect(attachYDocExtensionsToCodeMirror, [codeMirrorEditor, provider, ydoc]);
+  useEffect(initializeEditor, [codeMirrorEditor, isInit, onOpenEditor, ydoc]);
+};