2
0
Эх сурвалжийг харах

WIP: implement useSecondaryYdocs

Yuki Takei 1 жил өмнө
parent
commit
65bfd653ac

+ 59 - 43
packages/editor/src/client/services-internal/unified-merge-view/use-unified-merge-view.ts

@@ -2,64 +2,80 @@ import { useEffect } from 'react';
 
 import {
   unifiedMergeView,
-  originalDocChangeEffect,
-  getOriginalDoc,
+  // originalDocChangeEffect,
+  // getOriginalDoc,
 } from '@codemirror/merge';
-import { StateField, ChangeSet } from '@codemirror/state';
+// import { StateField, ChangeSet } from '@codemirror/state';
 
-import { CollaborativeChange } from '../../../consts/collaborative-change';
-import { deltaToChangeSpecs } from '../../../utils/delta-to-changespecs';
+import * as Y from 'yjs';
+
+// import { CollaborativeChange } from '../../../consts/collaborative-change';
+// import { deltaToChangeSpecs } from '../../../utils/delta-to-changespecs';
 import type { UseCodeMirrorEditor } from '../../services';
+import { useSecondaryYdocs } from '../../stores/use-secondary-ydocs';
+
 
+type Configuration = {
+  pageId?: string,
+}
 
 export const useUnifiedMergeView = (
-    unifiedMergeViewEnabled?: boolean,
+    isEnabled = false,
     codeMirrorEditor?: UseCodeMirrorEditor,
+    configuration?: Configuration,
 ): void => {
 
+  // const { pageId } = configuration ?? {};
+
+  // const { primaryDoc, secondaryDoc } = useSecondaryYdocs({
+  //   isEnabled,
+  //   pageId,
+  //   useSecondary: isEnabled,
+  // }) ?? {};
+
   // setup unifiedMergeView
-  useEffect(() => {
-    if (unifiedMergeViewEnabled == null || !codeMirrorEditor) {
-      return;
-    }
+  // useEffect(() => {
+  //   if (!isEnabled || primaryDoc == null || secondaryDoc == null || codeMirrorEditor == null) {
+  //     return;
+  //   }
 
-    const extension = unifiedMergeViewEnabled ? [
-      unifiedMergeView({
-        original: codeMirrorEditor.getDoc(),
-      }),
-    ] : [];
+  //   const extension = isEnabled ? [
+  //     unifiedMergeView({
+  //       original: primaryDoc.getText('codemirror').toString(),
+  //     }),
+  //   ] : [];
 
-    const cleanupFunction = codeMirrorEditor.appendExtensions(extension);
-    return cleanupFunction;
-  }, [codeMirrorEditor, unifiedMergeViewEnabled]);
+  //   const cleanupFunction = codeMirrorEditor?.appendExtensions(extension);
+  //   return cleanupFunction;
+  // }, [isEnabled, pageId, codeMirrorEditor, primaryDoc, secondaryDoc]);
 
   // effect for updating orignal document by collaborative changes
-  useEffect(() => {
-    if (!unifiedMergeViewEnabled || codeMirrorEditor == null) {
-      return;
-    }
+  // useEffect(() => {
+  //   if (!isEnabled || codeMirrorEditor == null) {
+  //     return;
+  //   }
 
-    const extension = StateField.define({
-      create: () => null,
-      update(value, tr) {
-        for (const e of tr.effects) {
-          if (e.is(CollaborativeChange)) {
-            const changeSpecs = deltaToChangeSpecs(e.value);
-            const changeSet = ChangeSet.of(changeSpecs, getOriginalDoc(tr.state).length);
-            const effect = originalDocChangeEffect(tr.state, changeSet);
-            setTimeout(() => {
-              codeMirrorEditor.view?.dispatch({
-                effects: effect,
-              });
-            }, 0);
-          }
-        }
+  //   const extension = StateField.define({
+  //     create: () => null,
+  //     update(value, tr) {
+  //       for (const e of tr.effects) {
+  //         if (e.is(CollaborativeChange)) {
+  //           const changeSpecs = deltaToChangeSpecs(e.value);
+  //           const changeSet = ChangeSet.of(changeSpecs, getOriginalDoc(tr.state).length);
+  //           const effect = originalDocChangeEffect(tr.state, changeSet);
+  //           setTimeout(() => {
+  //             codeMirrorEditor.view?.dispatch({
+  //               effects: effect,
+  //             });
+  //           }, 0);
+  //         }
+  //       }
 
-        return value;
-      },
-    });
+  //       return value;
+  //     },
+  //   });
 
-    const cleanupFunction = codeMirrorEditor.appendExtensions(extension);
-    return cleanupFunction;
-  }, [codeMirrorEditor, unifiedMergeViewEnabled]);
+  //   const cleanupFunction = codeMirrorEditor.appendExtensions(extension);
+  //   return cleanupFunction;
+  // }, [codeMirrorEditor, isEnabled]);
 };

+ 93 - 128
packages/editor/src/client/stores/use-collaborative-editor-mode.ts

@@ -2,28 +2,23 @@ import { useEffect, useState } from 'react';
 
 import { keymap } from '@codemirror/view';
 import type { IUserHasId } from '@growi/core/dist/interfaces';
-import { useGlobalSocket } from '@growi/core/dist/swr';
 import { yCollab, yUndoManagerKeymap } from 'y-codemirror.next';
 import { SocketIOProvider } from 'y-socket.io';
 import * as Y from 'yjs';
 
 import { userColor } from '../../consts';
-import { CollaborativeChange } from '../../consts/collaborative-change';
+// import { CollaborativeChange } from '../../consts/collaborative-change';
+import type { EditingClient } from '../../interfaces';
 import type { UseCodeMirrorEditor } from '../services';
 
-type UserLocalState = {
-  name: string;
-  user?: IUserHasId;
-  color: string;
-  colorLight: string;
-}
+import { useSecondaryYdocs } from './use-secondary-ydocs';
+
 
 type Configuration = {
-  reviewMode?: boolean,
   user?: IUserHasId,
   pageId?: string,
-  initialValue?: string,
-  onEditorsUpdated?: (userList: IUserHasId[]) => void,
+  reviewMode?: boolean,
+  onEditorsUpdated?: (clientList: EditingClient[]) => void,
 }
 
 export const useCollaborativeEditorMode = (
@@ -32,149 +27,120 @@ export const useCollaborativeEditorMode = (
     configuration?: Configuration,
 ): void => {
   const {
-    user, pageId, initialValue, onEditorsUpdated, reviewMode,
+    user, pageId, onEditorsUpdated, reviewMode,
   } = configuration ?? {};
 
-  const [ydoc, setYdoc] = useState<Y.Doc | null>(null);
-  const [reviewDoc, setReviewDoc] = useState<Y.Doc | null>(null);
-  const [provider, setProvider] = useState<SocketIOProvider | null>(null);
-  const [cPageId, setCPageId] = useState(pageId);
-
-  const { data: socket } = useGlobalSocket();
-
-  // Cleanup Ydoc
-  useEffect(() => {
-    if (cPageId === pageId && isEnabled) {
-      return;
-    }
-
-    ydoc?.destroy();
-    setYdoc(null);
+  const { primaryDoc, activeDoc } = useSecondaryYdocs({
+    isEnabled,
+    pageId,
+    useSecondary: reviewMode,
+  }) ?? {};
 
-    // NOTICE: Destroying the provider leaves awareness in the other user's connection,
-    // so only awareness is destroyed here
-    provider?.awareness.destroy();
+  const [provider, setProvider] = useState<SocketIOProvider>();
 
-    setCPageId(pageId);
 
-    // reset editors
-    onEditorsUpdated?.([]);
-  }, [cPageId, isEnabled, onEditorsUpdated, pageId, provider?.awareness, socket, ydoc]);
-
-  // Setup Ydoc
-  useEffect(() => {
-    if (ydoc != null || !isEnabled) {
-      return;
-    }
-
-    // NOTICE: Old provider destroy at the time of ydoc setup,
-    // because the awareness destroying is not sync to other clients
-    provider?.destroy();
-    setProvider(null);
-
-    const _ydoc = new Y.Doc();
-    setYdoc(_ydoc);
-  }, [isEnabled, provider, ydoc, reviewMode]);
-
-  // Setup Ydoc for review mode
+  // reset editors
   useEffect(() => {
-    if (reviewDoc != null || !isEnabled || !reviewMode) {
-      return;
-    }
-
-    const _reviewDoc = new Y.Doc();
-    setReviewDoc(_reviewDoc);
-  }, [isEnabled, reviewMode, reviewDoc]);
-
-  // Apply reviewDoc to Ydoc
-  useEffect(() => {
-    if (reviewDoc == null || ydoc == null || !isEnabled || reviewMode) {
-      return;
-    }
-
-    Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(reviewDoc));
-  }, [isEnabled, reviewMode, reviewDoc, ydoc]);
+    if (!isEnabled) return;
+    onEditorsUpdated?.([]);
+  }, [isEnabled, onEditorsUpdated]);
 
   // Setup provider
   useEffect(() => {
-    if (provider != null || pageId == null || ydoc == null || socket == null) {
-      return;
-    }
 
-    const socketIOProvider = new SocketIOProvider(
-      '/',
-      pageId,
-      ydoc,
-      {
-        autoConnect: true,
-        resyncInterval: 3000,
-      },
-    );
-
-    const userLocalState: UserLocalState = {
-      name: user?.name ? `${user.name}` : `Guest User ${Math.floor(Math.random() * 100)}`,
-      user,
-      color: userColor.color,
-      colorLight: userColor.light,
-    };
+    let _provider: SocketIOProvider | undefined;
+    let providerSyncHandler: (isSync: boolean) => void;
+    let updateAwarenessHandler: (update: { added: number[]; updated: number[]; removed: number[]; }) => void;
 
-    socketIOProvider.awareness.setLocalStateField('user', userLocalState);
-
-    socketIOProvider.on('sync', (isSync: boolean) => {
-      if (isSync && onEditorsUpdated != null) {
-        const userList: IUserHasId[] = Array.from(socketIOProvider.awareness.states.values(), value => value.user.user && value.user.user);
-        onEditorsUpdated(userList);
+    setProvider(() => {
+      if (!isEnabled || pageId == null || primaryDoc == null) {
+        return undefined;
       }
-    });
-
-    // update args type see: SocketIOProvider.Awareness.awarenessUpdate
-    socketIOProvider.awareness.on('update', (update: { added: unknown[]; removed: unknown[]; }) => {
-      if (onEditorsUpdated == null) return;
 
-      const { added, removed } = update;
-      if (added.length > 0 || removed.length > 0) {
-        const userList: IUserHasId[] = Array.from(socketIOProvider.awareness.states.values(), value => value.user.user && value.user.user);
-        onEditorsUpdated(userList);
-      }
+      _provider = new SocketIOProvider(
+        '/',
+        pageId,
+        primaryDoc,
+        {
+          autoConnect: true,
+          resyncInterval: 3000,
+        },
+      );
+
+      const userLocalState: EditingClient = {
+        clientId: primaryDoc.clientID,
+        name: user?.name ? `${user.name}` : `Guest User ${Math.floor(Math.random() * 100)}`,
+        userId: user?._id,
+        color: userColor.color,
+        colorLight: userColor.light,
+      };
+
+      const { awareness } = _provider;
+      awareness.setLocalStateField('editors', userLocalState);
+
+      providerSyncHandler = (isSync: boolean) => {
+        if (isSync && onEditorsUpdated != null) {
+          const clientList: EditingClient[] = Array.from(awareness.getStates().values(), value => value.editors);
+          if (Array.isArray(clientList)) {
+            onEditorsUpdated(clientList);
+          }
+        }
+      };
+
+      _provider.on('sync', providerSyncHandler);
+
+      // update args type see: SocketIOProvider.Awareness.awarenessUpdate
+      updateAwarenessHandler = (update: { added: number[]; updated: number[]; removed: number[]; }) => {
+        // remove the states of disconnected clients
+        update.removed.forEach(clientId => awareness.states.delete(clientId));
+
+        // update editor list
+        if (onEditorsUpdated != null) {
+          const clientList: EditingClient[] = Array.from(awareness.states.values(), value => value.editors);
+          if (Array.isArray(clientList)) {
+            onEditorsUpdated(clientList);
+          }
+        }
+      };
+
+      awareness.on('update', updateAwarenessHandler);
+
+      return _provider;
     });
 
-    setProvider(socketIOProvider);
-  }, [initialValue, onEditorsUpdated, pageId, provider, socket, user, ydoc]);
+    return () => {
+      _provider?.awareness.setLocalState(null);
+      _provider?.awareness.off('update', updateAwarenessHandler);
+      _provider?.off('sync', providerSyncHandler);
+      _provider?.disconnect();
+      _provider?.destroy();
+    };
+  }, [isEnabled, primaryDoc, onEditorsUpdated, pageId, user]);
 
   // Setup Ydoc Extensions
   useEffect(() => {
-    if (ydoc == null || provider == null || codeMirrorEditor == null) {
+    if (!isEnabled || !primaryDoc || !activeDoc || !provider || !codeMirrorEditor) {
       return;
     }
 
-    const ytext = ydoc.getText('codemirror');
-    const reviewText = reviewMode ? reviewDoc?.getText('codemirror') : null;
+    const activeText = activeDoc.getText('codemirror');
 
     // setup observer to mark collaborative changes
-    ytext.observe((event) => {
-      if (event.transaction.local) return;
-
-      codeMirrorEditor.view?.dispatch({
-        effects: CollaborativeChange.of(event.delta),
-      });
+    // primaryDoc.getText('codemirror').observe((event) => {
+    //   if (event.transaction.local) return;
+    //   codeMirrorEditor.view?.dispatch({
+    //     effects: CollaborativeChange.of(event.delta),
+    //   });
+    // });
 
-      // レビューモード時は変更をレビュー用のドキュメントにも反映
-      if (reviewMode && reviewText != null) {
-        Y.applyUpdate(reviewDoc!, Y.encodeStateAsUpdate(ydoc));
-      }
-    });
+    const undoManager = new Y.UndoManager(activeText);
 
-    // const undoManager = new Y.UndoManager(ytext);
-    const undoManager = new Y.UndoManager(reviewMode && reviewText ? reviewText : ytext);
+    // initialize document with activeDoc text
+    codeMirrorEditor.initDoc(activeText.toString());
 
     const extensions = [
       keymap.of(yUndoManagerKeymap),
-      // yCollab(ytext, provider.awareness, { undoManager }),
-      yCollab(
-        reviewMode && reviewText ? reviewText : ytext,
-        provider.awareness,
-        { undoManager },
-      ),
+      yCollab(activeText, provider.awareness, { undoManager }),
     ];
 
     const cleanupFunctions = extensions.map(ext => codeMirrorEditor.appendExtensions([ext]));
@@ -183,6 +149,5 @@ export const useCollaborativeEditorMode = (
       cleanupFunctions.forEach(cleanup => cleanup?.());
       codeMirrorEditor.initDoc('');
     };
-  // }, [codeMirrorEditor, provider, ydoc, reviewMode]);
-  }, [codeMirrorEditor, provider, ydoc, reviewDoc, reviewMode]);
+  }, [isEnabled, codeMirrorEditor, provider, primaryDoc, activeDoc, reviewMode]);
 };

+ 102 - 0
packages/editor/src/client/stores/use-secondary-ydocs.ts

@@ -0,0 +1,102 @@
+import { useEffect, useState } from 'react';
+
+import * as Y from 'yjs';
+
+type UseDocumentStateProps = {
+  isEnabled: boolean;
+  pageId?: string;
+  useSecondary?: boolean;
+}
+
+type DocumentState = {
+  activeDoc: Y.Doc,
+  primaryDoc: Y.Doc,
+  secondaryDoc?: Y.Doc,
+}
+
+export const useSecondaryYdocs = ({ isEnabled, pageId, useSecondary }: UseDocumentStateProps): DocumentState | null => {
+
+  const [currentPageId, setCurrentPageId] = useState(pageId);
+
+  // docs: [primaryDoc, secondaryDoc]
+  const [docs, setDocs] = useState<[Y.Doc, Y.Doc]>();
+
+  // Setup doc
+  useEffect(() => {
+
+    let _primaryDoc: Y.Doc;
+    let _secondaryDoc: Y.Doc;
+
+    setDocs((prevDocs) => {
+      // keep the current docs if the conditions are met
+      if (isEnabled
+          // the given page ID is not null
+          && pageId != null
+          // the current page ID matches the given page ID,
+          && currentPageId === pageId
+          // the main document is already initialized
+          && prevDocs?.[0] != null
+          // the review mode status matches the presence of the review document
+          && useSecondary === (prevDocs?.[1] != null)) {
+
+        return prevDocs;
+      }
+
+      setCurrentPageId(pageId);
+
+      // set undefined
+      if (!isEnabled) {
+        return undefined;
+      }
+
+      _primaryDoc = prevDocs?.[0] ?? new Y.Doc();
+
+      if (useSecondary) {
+        _secondaryDoc = prevDocs?.[1] ?? new Y.Doc();
+
+        const text = _primaryDoc.getText('codemirror');
+
+        // initialize secondaryDoc with primaryDoc state
+        Y.applyUpdate(_secondaryDoc, Y.encodeStateAsUpdate(_primaryDoc));
+        // Setup sync from primaryDoc to secondaryDoc
+        text.observe((event) => {
+          if (event.transaction.local) return;
+          Y.applyUpdate(_secondaryDoc, Y.encodeStateAsUpdate(_primaryDoc));
+        });
+      }
+
+      return [_primaryDoc, _secondaryDoc];
+    });
+
+    // cleanup
+    return () => {
+      _primaryDoc?.destroy();
+      _secondaryDoc?.destroy();
+    };
+
+  }, [isEnabled, currentPageId, pageId, useSecondary]);
+
+  // Handle secondaryDoc to primaryDoc sync when exiting review mode
+  // useEffect(() => {
+  //   if (!isEnabled || reviewMode || !secondaryDoc || !primaryDoc) {
+  //     return;
+  //   }
+
+  //   Y.applyUpdate(primaryDoc, Y.encodeStateAsUpdate(secondaryDoc));
+  //   secondaryDoc.destroy();
+  //   setsecondaryDoc(null);
+  // }, [isEnabled, reviewMode, secondaryDoc, primaryDoc]);
+
+  const [primaryDoc, secondaryDoc] = docs ?? [undefined, undefined];
+
+  if (primaryDoc == null || (useSecondary && secondaryDoc == null)) {
+    return null;
+  }
+
+  return {
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    activeDoc: useSecondary ? secondaryDoc! : primaryDoc,
+    primaryDoc,
+    secondaryDoc,
+  };
+};