use-collaborative-editor-mode.ts 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. import { useEffect, useState } from 'react';
  2. import { GlobalSocketEventName, type IUserHasId } from '@growi/core/dist/interfaces';
  3. import { useGlobalSocket, GLOBAL_SOCKET_NS } from '@growi/core/dist/swr';
  4. // see: https://github.com/yjs/y-codemirror.next#example
  5. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  6. // @ts-ignore
  7. import { yCollab } from 'y-codemirror.next';
  8. import { SocketIOProvider } from 'y-socket.io';
  9. import * as Y from 'yjs';
  10. import { userColor } from '../consts';
  11. import { UseCodeMirrorEditor } from '../services';
  12. type UserLocalState = {
  13. name: string;
  14. user?: IUserHasId;
  15. color: string;
  16. colorLight: string;
  17. }
  18. export const useCollaborativeEditorMode = (
  19. user?: IUserHasId,
  20. pageId?: string,
  21. initialValue?: string,
  22. onOpenEditor?: (markdown: string) => void,
  23. onEditorsUpdated?: (userList: IUserHasId[]) => void,
  24. codeMirrorEditor?: UseCodeMirrorEditor,
  25. ): void => {
  26. const [ydoc, setYdoc] = useState<Y.Doc | null>(null);
  27. const [provider, setProvider] = useState<SocketIOProvider | null>(null);
  28. const [isInit, setIsInit] = useState(false);
  29. const [cPageId, setCPageId] = useState(pageId);
  30. const { data: socket } = useGlobalSocket();
  31. const cleanupYDocAndProvider = () => {
  32. if (cPageId === pageId) {
  33. return;
  34. }
  35. ydoc?.destroy();
  36. setYdoc(null);
  37. // NOTICE: Destroying the provider leaves awareness in the other user's connection,
  38. // so only awareness is destroyed here
  39. provider?.awareness.destroy();
  40. // TODO: catch ydoc:sync:error GlobalSocketEventName.YDocSyncError
  41. socket?.off(GlobalSocketEventName.YDocSync);
  42. setIsInit(false);
  43. setCPageId(pageId);
  44. // reset editors
  45. onEditorsUpdated?.([]);
  46. };
  47. const setupYDoc = () => {
  48. if (ydoc != null) {
  49. return;
  50. }
  51. // NOTICE: Old provider destroy at the time of ydoc setup,
  52. // because the awareness destroying is not sync to other clients
  53. provider?.destroy();
  54. setProvider(null);
  55. const _ydoc = new Y.Doc();
  56. setYdoc(_ydoc);
  57. };
  58. const setupProvider = () => {
  59. if (provider != null || ydoc == null || socket == null || onEditorsUpdated == null) {
  60. return;
  61. }
  62. const socketIOProvider = new SocketIOProvider(
  63. GLOBAL_SOCKET_NS,
  64. `yjs/${pageId}`,
  65. ydoc,
  66. { autoConnect: true },
  67. );
  68. const userLocalState: UserLocalState = {
  69. name: user?.name ? `${user.name}` : `Guest User ${Math.floor(Math.random() * 100)}`,
  70. user,
  71. color: userColor.color,
  72. colorLight: userColor.light,
  73. };
  74. socketIOProvider.awareness.setLocalStateField('user', userLocalState);
  75. socketIOProvider.on('sync', (isSync: boolean) => {
  76. if (isSync) {
  77. socket.emit(GlobalSocketEventName.YDocSync, { pageId, initialValue });
  78. const userList: IUserHasId[] = Array.from(socketIOProvider.awareness.states.values(), value => value.user.user && value.user.user);
  79. onEditorsUpdated(userList);
  80. }
  81. });
  82. // update args type see: SocketIOProvider.Awareness.awarenessUpdate
  83. socketIOProvider.awareness.on('update', (update: any) => {
  84. const { added, removed } = update;
  85. if (added.length > 0 || removed.length > 0) {
  86. const userList: IUserHasId[] = Array.from(socketIOProvider.awareness.states.values(), value => value.user.user && value.user.user);
  87. onEditorsUpdated(userList);
  88. }
  89. });
  90. setProvider(socketIOProvider);
  91. };
  92. const setupYDocExtensions = () => {
  93. if (ydoc == null || provider == null) {
  94. return;
  95. }
  96. const ytext = ydoc.getText('codemirror');
  97. const undoManager = new Y.UndoManager(ytext);
  98. const cleanup = codeMirrorEditor?.appendExtensions?.([
  99. yCollab(ytext, provider.awareness, { undoManager }),
  100. ]);
  101. return cleanup;
  102. };
  103. const initializeEditor = () => {
  104. if (ydoc == null || onOpenEditor == null || isInit === true) {
  105. return;
  106. }
  107. const ytext = ydoc.getText('codemirror');
  108. codeMirrorEditor?.initDoc(ytext.toString());
  109. onOpenEditor(ytext.toString());
  110. setIsInit(true);
  111. };
  112. useEffect(cleanupYDocAndProvider, [cPageId, onEditorsUpdated, pageId, provider, socket, ydoc]);
  113. useEffect(setupYDoc, [provider, ydoc]);
  114. useEffect(setupProvider, [initialValue, onEditorsUpdated, pageId, provider, socket, user, ydoc]);
  115. useEffect(setupYDocExtensions, [codeMirrorEditor, provider, ydoc]);
  116. useEffect(initializeEditor, [codeMirrorEditor, isInit, onOpenEditor, ydoc]);
  117. };