From 921f5ef290ccbe46acd1165ec55424ed8cb121ad Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Mon, 18 Nov 2024 22:06:09 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20socket.io=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8A=94=20useSocket=20=ED=9B=85=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RemoteInsert, RemoteDelete, RemoteCursor 등의 메소드 삽입 - socket 조건에 따라 서버와 통신 - 추후에 API관련 정리할 예정 #109 Co-authored-by: Jang seo yun --- client/src/apis/useSocket.ts | 106 ++++++++++++++++++++++++++ client/src/features/editor/Editor.tsx | 20 ++--- 2 files changed, 117 insertions(+), 9 deletions(-) create mode 100644 client/src/apis/useSocket.ts diff --git a/client/src/apis/useSocket.ts b/client/src/apis/useSocket.ts new file mode 100644 index 00000000..df13554c --- /dev/null +++ b/client/src/apis/useSocket.ts @@ -0,0 +1,106 @@ +import { + RemoteInsertOperation, + RemoteDeleteOperation, + CursorPosition, +} from "@noctaCrdt/Interfaces"; +import { useEffect, useRef } from "react"; +import { io, Socket } from "socket.io-client"; + +// 구독 핸들러들의 타입 정의 +interface RemoteOperationHandlers { + onRemoteInsert: (operation: RemoteInsertOperation) => void; + onRemoteDelete: (operation: RemoteDeleteOperation) => void; + onRemoteCursor: (position: CursorPosition) => void; +} + +// 훅의 반환 타입을 명시적으로 정의 +interface UseSocketReturn { + socket: Socket | null; + sendInsertOperation: (operation: RemoteInsertOperation) => void; + sendDeleteOperation: (operation: RemoteDeleteOperation) => void; + sendCursorPosition: (position: CursorPosition) => void; + subscribeToRemoteOperations: (handlers: RemoteOperationHandlers) => (() => void) | undefined; +} + +// 반환 타입을 명시적으로 지정 +export const useSocket = (): UseSocketReturn => { + const socketRef = useRef(null); + + useEffect(() => { + const SERVER_URL = + process.env.NODE_ENV === "development" ? "http://localhost:3000" : "https://api.nocta.site"; + socketRef.current = io(SERVER_URL, { + path: "/api/socket.io", + transports: ["websocket", "polling"], // polling도 fallback으로 추가 + withCredentials: true, // CORS credentials 설정 + reconnectionAttempts: 5, // 재연결 시도 횟수 + reconnectionDelay: 1000, // 재연결 시도 간격 (ms) + autoConnect: true, + }); + + socketRef.current.on("assignId", (clientId: number) => { + console.log("Assigned client ID:", clientId); + }); + + socketRef.current.on("document", (document: any) => { + console.log("Received initial document state:", document); + }); + + socketRef.current.on("connect", () => { + console.log("Connected to server"); + }); + + socketRef.current.on("disconnect", () => { + console.log("Disconnected from server"); + }); + + socketRef.current.on("error", (error: Error) => { + console.error("Socket error:", error); + }); + + return () => { + if (socketRef.current) { + socketRef.current.disconnect(); + } + }; + }, []); + + const sendInsertOperation = (operation: RemoteInsertOperation) => { + socketRef.current?.emit("insert", operation); + console.log(operation); + }; + + const sendDeleteOperation = (operation: RemoteDeleteOperation) => { + socketRef.current?.emit("delete", operation); + }; + + const sendCursorPosition = (position: CursorPosition) => { + socketRef.current?.emit("cursor", position); + }; + + const subscribeToRemoteOperations = ({ + onRemoteInsert, + onRemoteDelete, + onRemoteCursor, + }: RemoteOperationHandlers) => { + if (!socketRef.current) return; + + socketRef.current.on("insert", onRemoteInsert); + socketRef.current.on("delete", onRemoteDelete); + socketRef.current.on("cursor", onRemoteCursor); + + return () => { + socketRef.current?.off("insert", onRemoteInsert); + socketRef.current?.off("delete", onRemoteDelete); + socketRef.current?.off("cursor", onRemoteCursor); + }; + }; + + return { + socket: socketRef.current, + sendInsertOperation, + sendDeleteOperation, + sendCursorPosition, + subscribeToRemoteOperations, + }; +}; diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index 78025e63..3659466b 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -5,6 +5,7 @@ import { BlockLinkedList } from "@noctaCrdt/LinkedList"; import { Block as CRDTBlock } from "@noctaCrdt/Node"; import { BlockId } from "@noctaCrdt/NodeId"; import { useRef, useState, useCallback, useEffect } from "react"; +import { useSocket } from "@src/apis/useSocket.ts"; import { editorContainer, editorTitleContainer, editorTitle } from "./Editor.style"; import { Block } from "./components/block/Block.tsx"; import { useBlockDragAndDrop } from "./hooks/useBlockDragAndDrop"; @@ -22,7 +23,7 @@ export interface EditorStateProps { export const Editor = ({ onTitleChange }: EditorProps) => { const editorCRDT = useRef(new EditorCRDT(0)); - + const { sendInsertOperation, sendDeleteOperation } = useSocket(); const [editorState, setEditorState] = useState({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, @@ -73,7 +74,7 @@ export const Editor = ({ onTitleChange }: EditorProps) => { (e: React.FormEvent, blockId: BlockId) => { const block = editorState.linkedList.getNode(blockId); if (!block) return; - + let testNode; const element = e.currentTarget; const newContent = element.textContent || ""; const currentContent = block.crdt.read(); @@ -81,29 +82,30 @@ export const Editor = ({ onTitleChange }: EditorProps) => { const caretPosition = selection?.focusOffset || 0; if (newContent.length > currentContent.length) { - // 텍스트 추가 로직 if (caretPosition === 0) { const [addedChar] = newContent; - block.crdt.localInsert(0, addedChar); + testNode = block.crdt.localInsert(0, addedChar); block.crdt.currentCaret = 1; } else if (caretPosition > currentContent.length) { const addedChar = newContent[newContent.length - 1]; - block.crdt.localInsert(currentContent.length, addedChar); + testNode = block.crdt.localInsert(currentContent.length, addedChar); block.crdt.currentCaret = caretPosition; } else { const addedChar = newContent[caretPosition - 1]; - block.crdt.localInsert(caretPosition - 1, addedChar); + testNode = block.crdt.localInsert(caretPosition - 1, addedChar); block.crdt.currentCaret = caretPosition; } + sendInsertOperation(testNode); } else if (newContent.length < currentContent.length) { - // 텍스트 삭제 로직 if (caretPosition === 0) { - block.crdt.localDelete(0); + testNode = block.crdt.localDelete(0); block.crdt.currentCaret = 0; } else { - block.crdt.localDelete(caretPosition); + testNode = block.crdt.localDelete(caretPosition); block.crdt.currentCaret = caretPosition; } + console.log(testNode); + sendDeleteOperation(testNode); } setEditorState((prev) => ({ From 3cd478730967a989a3155416282b2125e0348a40 Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Mon, 18 Nov 2024 22:07:01 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20server=20Logger=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nestJS에서 기본으로 제공하는 라이브러리 활용 --- server/src/crdt/crdt.gateway.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/src/crdt/crdt.gateway.ts b/server/src/crdt/crdt.gateway.ts index 44f0e3c7..06ebbb14 100644 --- a/server/src/crdt/crdt.gateway.ts +++ b/server/src/crdt/crdt.gateway.ts @@ -14,6 +14,14 @@ import { RemoteDeleteOperation, CursorPosition, } from "@noctaCrdt/Interfaces"; +import { Logger } from "@nestjs/common"; +import { NodeId } from "@noctaCrdt/NodeId"; + +// 클라이언트 맵 타입 정의 +interface ClientInfo { + clientId: number; + connectionTime: Date; +} @WebSocketGateway({ cors: { From 4d83d139b1a06c44789cf3528d75af9e88de5f27 Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Mon, 18 Nov 2024 22:07:43 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20wsException=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - websocket Exception 라이브러리 활용 --- server/src/crdt/crdt.gateway.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/crdt/crdt.gateway.ts b/server/src/crdt/crdt.gateway.ts index 06ebbb14..01d66a97 100644 --- a/server/src/crdt/crdt.gateway.ts +++ b/server/src/crdt/crdt.gateway.ts @@ -6,6 +6,7 @@ import { OnGatewayDisconnect, MessageBody, ConnectedSocket, + WsException, } from "@nestjs/websockets"; import { Socket, Server } from "socket.io"; import { CrdtService } from "./crdt.service"; From d6eb85c376a42a4d766bb0a9694e6bef17b94794 Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Mon, 18 Nov 2024 22:08:59 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20client=EC=99=80=20CRDT=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CRDT 역직렬화 연산 추가 - 로거 및 API에 맞게 emit연산 추가 - 추후 API 수정 예정 #109 --- server/src/crdt/crdt.gateway.ts | 167 +++++++++++++++++++++++++------- 1 file changed, 134 insertions(+), 33 deletions(-) diff --git a/server/src/crdt/crdt.gateway.ts b/server/src/crdt/crdt.gateway.ts index 01d66a97..ca9f29eb 100644 --- a/server/src/crdt/crdt.gateway.ts +++ b/server/src/crdt/crdt.gateway.ts @@ -26,82 +26,183 @@ interface ClientInfo { @WebSocketGateway({ cors: { - origin: "*", // 실제 배포 시에는 보안을 위해 적절히 설정하세요 + origin: + process.env.NODE_ENV === "development" + ? "http://localhost:5173" // Vite 개발 서버 포트 + : ["https://nocta.site", "https://www.nocta.site"], + credentials: true, }, + path: "/api/socket.io", + transports: ["websocket", "polling"], }) export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { + private readonly logger = new Logger(CrdtGateway.name); private server: Server; private clientIdCounter: number = 1; - private clientMap: Map = new Map(); // socket.id -> clientId - + private clientMap: Map = new Map(); + private guestMap; + private guestIdCounter; constructor(private readonly crdtService: CrdtService) {} afterInit(server: Server) { this.server = server; } - /** - * 초기에 연결될때, 클라이언트에 숫자id및 문서정보를 송신한다. - * @param client 클라이언트 socket 정보 + * 클라이언트 연결 처리 + * 새로운 클라이언트에게 ID를 할당하고 현재 문서 상태를 전송 */ async handleConnection(client: Socket) { - console.log(`클라이언트 연결: ${client.id}`); - const assignedId = (this.clientIdCounter += 1); - this.clientMap.set(client.id, assignedId); - client.emit("assignId", assignedId); - const currentCRDT = this.crdtService.getCRDT().serialize(); - client.emit("document", currentCRDT); + try { + const assignedId = this.clientIdCounter++; + const clientInfo: ClientInfo = { + clientId: assignedId, + connectionTime: new Date(), + }; + this.clientMap.set(client.id, clientInfo); + + // 클라이언트에게 ID 할당 + client.emit("assignId", assignedId); + + // 현재 문서 상태 전송 + const currentCRDT = await this.crdtService.getCRDT().serialize(); + client.emit("document", currentCRDT); + + // 다른 클라이언트들에게 새 사용자 입장 알림 + client.broadcast.emit("userJoined", { clientId: assignedId }); + + this.logger.log(`클라이언트 연결 성공 - Socket ID: ${client.id}, Client ID: ${assignedId}`); + this.logger.debug(`현재 연결된 클라이언트 수: ${this.clientMap.size}`); + } catch (error) { + this.logger.error(`클라이언트 연결 중 오류 발생: ${error.message}`, error.stack); + client.disconnect(); + } } /** - * 연결이 끊어지면 클라이언트 맵에서 클라이언트 삭제 - * @param client 클라이언트 socket 정보 + * 클라이언트 연결 해제 처리 */ handleDisconnect(client: Socket) { - console.log(`클라이언트 연결 해제: ${client.id}`); - this.clientMap.delete(client.id); + try { + const clientInfo = this.clientMap.get(client.id); + if (clientInfo) { + // 다른 클라이언트들에게 사용자 퇴장 알림 + client.broadcast.emit("userLeft", { clientId: clientInfo.clientId }); + + // 연결 시간 계산 + const connectionDuration = new Date().getTime() - clientInfo.connectionTime.getTime(); + this.logger.log( + `클라이언트 연결 해제 - Socket ID: ${client.id}, ` + + `Client ID: ${clientInfo.clientId}, ` + + `연결 시간: ${Math.round(connectionDuration / 1000)}초`, + ); + } + + this.clientMap.delete(client.id); + this.logger.debug(`남은 연결된 클라이언트 수: ${this.clientMap.size}`); + } catch (error) { + this.logger.error(`클라이언트 연결 해제 중 오류 발생: ${error.message}`, error.stack); + } } /** - * 클라이언트로부터 받은 원격 삽입 연산 - * @param data 클라이언트가 송신한 Node 정보 - * @param client 클라이언트 번호 + * 삽입 연산 처리 */ @SubscribeMessage("insert") async handleInsert( @MessageBody() data: RemoteInsertOperation, @ConnectedSocket() client: Socket, ): Promise { - console.log(`Insert 연산 수신 from ${client.id}:`, data); + const clientInfo = this.clientMap.get(client.id); + try { + this.logger.debug( + `Insert 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, + JSON.stringify(data), + ); - await this.crdtService.handleInsert(data); + // CRDT 연산 처리 + await this.crdtService.handleInsert(data); - client.broadcast.emit("insert", data); + // 다른 클라이언트들에게 연산 브로드캐스트 + client.broadcast.emit("insert", { + ...data, + timestamp: new Date().toISOString(), + sourceClientId: clientInfo?.clientId, + }); + } catch (error) { + this.logger.error( + `Insert 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, + error.stack, + ); + throw new WsException(`Insert 연산 실패: ${error.message}`); + } } /** - * 클라이언트로부터 받은 원격 삭제 연산 - * @param data 클라이언트가 송신한 Node 정보 - * @param client 클라이언트 번호 + * 삭제 연산 처리 */ @SubscribeMessage("delete") async handleDelete( @MessageBody() data: RemoteDeleteOperation, @ConnectedSocket() client: Socket, ): Promise { - console.log(`Delete 연산 수신 from ${client.id}:`, data); - await this.crdtService.handleDelete(data); - client.broadcast.emit("delete", data); + const clientInfo = this.clientMap.get(client.id); + try { + this.logger.debug( + `Delete 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, + JSON.stringify(data), + ); + + const deleteNode = new NodeId(data.clock, data.targetId.client); + await this.crdtService.handleDelete({ targetId: deleteNode, clock: data.clock }); + + client.broadcast.emit("delete", { + ...data, + timestamp: new Date().toISOString(), + sourceClientId: clientInfo?.clientId, + }); + } catch (error) { + this.logger.error( + `Delete 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, + error.stack, + ); + throw new WsException(`Delete 연산 실패: ${error.message}`); + } } /** - * 추후 caret 표시 기능을 위해 받아놓음 + 추후 개선때 인덱스 계산할때 캐럿으로 계산하면 용이할듯 하여 데이터로 만듦 - * @param data 클라이언트가 송신한 caret 정보 - * @param client 클라이언트 번호 + * 커서 위치 업데이트 처리 */ @SubscribeMessage("cursor") handleCursor(@MessageBody() data: CursorPosition, @ConnectedSocket() client: Socket): void { - console.log(`Cursor 위치 수신 from ${client.id}:`, data); - client.broadcast.emit("cursor", data); + const clientInfo = this.clientMap.get(client.id); + try { + this.logger.debug( + `Cursor 위치 업데이트 - Client ID: ${clientInfo?.clientId}, Position:`, + JSON.stringify(data), + ); + + // 커서 정보에 클라이언트 ID 추가하여 브로드캐스트 + client.broadcast.emit("cursor", { + ...data, + clientId: clientInfo?.clientId, + timestamp: new Date().toISOString(), + }); + } catch (error) { + this.logger.error( + `Cursor 업데이트 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, + error.stack, + ); + throw new WsException(`Cursor 업데이트 실패: ${error.message}`); + } + } + + /** + * 현재 연결된 모든 클라이언트 정보 조회 + */ + getConnectedClients(): { total: number; clients: ClientInfo[] } { + return { + total: this.clientMap.size, + clients: Array.from(this.clientMap.values()), + }; } } From 51ee8cca04874fa41c9d80175e7f01df94aa8015 Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Mon, 18 Nov 2024 22:14:16 +0900 Subject: [PATCH 05/10] =?UTF-8?q?chore:=20document=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20lint=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ++ => +=1; 로 수정 - Block, Char에 따라 직렬화props 되도록 유니온 선언 #109 --- client/src/apis/useSocket.ts | 6 ++++-- server/src/crdt/crdt.gateway.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/client/src/apis/useSocket.ts b/client/src/apis/useSocket.ts index df13554c..b5281a30 100644 --- a/client/src/apis/useSocket.ts +++ b/client/src/apis/useSocket.ts @@ -2,10 +2,11 @@ import { RemoteInsertOperation, RemoteDeleteOperation, CursorPosition, + SerializedProps, } from "@noctaCrdt/Interfaces"; +import { Block, Char } from "@noctaCrdt/Node"; import { useEffect, useRef } from "react"; import { io, Socket } from "socket.io-client"; - // 구독 핸들러들의 타입 정의 interface RemoteOperationHandlers { onRemoteInsert: (operation: RemoteInsertOperation) => void; @@ -42,7 +43,8 @@ export const useSocket = (): UseSocketReturn => { console.log("Assigned client ID:", clientId); }); - socketRef.current.on("document", (document: any) => { + socketRef.current.on("document", (document: SerializedProps | SerializedProps) => { + // 추후 확인 필요 console.log("Received initial document state:", document); }); diff --git a/server/src/crdt/crdt.gateway.ts b/server/src/crdt/crdt.gateway.ts index ca9f29eb..d47a503d 100644 --- a/server/src/crdt/crdt.gateway.ts +++ b/server/src/crdt/crdt.gateway.ts @@ -53,7 +53,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa */ async handleConnection(client: Socket) { try { - const assignedId = this.clientIdCounter++; + const assignedId = (this.clientIdCounter += 1); const clientInfo: ClientInfo = { clientId: assignedId, connectionTime: new Date(), From 51c81ba87086772e75fe29643a143f52580b5a6e Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Tue, 19 Nov 2024 00:30:52 +0900 Subject: [PATCH 06/10] =?UTF-8?q?refactor:=20CRDT=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20-=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - block, char 별로 interface 분리 - localInsert에서 block, char 별로 다르게 return 하게 처리 #109 --- @noctaCrdt/Crdt.ts | 39 ++++++---- @noctaCrdt/Interfaces.ts | 16 +++- client/src/apis/useSocket.ts | 48 ++++++++---- client/src/features/editor/Editor.tsx | 103 +++++++++++++++++++++----- server/src/crdt/crdt.gateway.ts | 97 ++++++++++++++++++++---- server/src/crdt/crdt.service.ts | 16 +++- 6 files changed, 251 insertions(+), 68 deletions(-) diff --git a/@noctaCrdt/Crdt.ts b/@noctaCrdt/Crdt.ts index 60ff30ee..535804c0 100644 --- a/@noctaCrdt/Crdt.ts +++ b/@noctaCrdt/Crdt.ts @@ -2,8 +2,10 @@ import { LinkedList } from "./LinkedList"; import { CharId, BlockId, NodeId } from "./NodeId"; import { Node, Char, Block } from "./Node"; import { - RemoteDeleteOperation, - RemoteInsertOperation, + RemoteBlockDeleteOperation, + RemoteCharDeleteOperation, + RemoteBlockInsertOperation, + RemoteCharInsertOperation, SerializedProps, RemoteReorderOperation, } from "./Interfaces"; @@ -19,18 +21,24 @@ export class CRDT> { this.LinkedList = new LinkedList(); } - localInsert(index: number, value: string): RemoteInsertOperation { - const id = - this instanceof BlockCRDT - ? new CharId(this.clock + 1, this.client) - : new BlockId(this.clock + 1, this.client); - - const remoteInsertion = this.LinkedList.insertAtIndex(index, value, id); - this.clock += 1; - return { node: remoteInsertion.node }; + localInsert( + index: number, + value: string, + ): RemoteBlockInsertOperation | RemoteCharInsertOperation { + if (this instanceof BlockCRDT) { + const id = new CharId(this.clock + 1, this.client); + const remoteInsertion = this.LinkedList.insertAtIndex(index, value, id); + this.clock += 1; + return { node: remoteInsertion.node, blockId: BlockId } as RemoteBlockInsertOperation; + } else { + const id = new BlockId(this.clock + 1, this.client); + const remoteInsertion = this.LinkedList.insertAtIndex(index, value, id); + this.clock += 1; + return { node: remoteInsertion.node } as RemoteCharInsertOperation; + } } - localDelete(index: number): RemoteDeleteOperation { + localDelete(index: number): RemoteBlockDeleteOperation | RemoteCharDeleteOperation { if (index < 0 || index >= this.LinkedList.spread().length) { throw new Error(`Invalid index: ${index}`); } @@ -40,7 +48,7 @@ export class CRDT> { throw new Error(`Node not found at index: ${index}`); } - const operation: RemoteDeleteOperation = { + const operation: RemoteBlockDeleteOperation | RemoteCharDeleteOperation = { targetId: nodeToDelete.id, clock: this.clock + 1, }; @@ -51,11 +59,12 @@ export class CRDT> { return operation; } - remoteInsert(operation: RemoteInsertOperation): void { + remoteInsert(operation: RemoteBlockInsertOperation | RemoteCharInsertOperation): void { const NodeIdClass = this instanceof BlockCRDT ? CharId : BlockId; const NodeClass = this instanceof BlockCRDT ? Char : Block; const newNodeId = new NodeIdClass(operation.node.id.clock, operation.node.id.client); + const newNode = new NodeClass(operation.node.value, newNodeId) as T; newNode.next = operation.node.next; newNode.prev = operation.node.prev; @@ -67,7 +76,7 @@ export class CRDT> { } } - remoteDelete(operation: RemoteDeleteOperation): void { + remoteDelete(operation: RemoteBlockDeleteOperation | RemoteCharDeleteOperation): void { const { targetId, clock } = operation; if (targetId) { this.LinkedList.deleteNode(targetId); diff --git a/@noctaCrdt/Interfaces.ts b/@noctaCrdt/Interfaces.ts index 21a7e5df..18b36a52 100644 --- a/@noctaCrdt/Interfaces.ts +++ b/@noctaCrdt/Interfaces.ts @@ -12,11 +12,21 @@ export interface DeleteOperation { clock: number; } -export interface RemoteInsertOperation { - node: Block | Char; +export interface RemoteBlockInsertOperation { + node: Block; +} + +export interface RemoteCharInsertOperation { + node: Char; + blockId: BlockId; +} + +export interface RemoteBlockDeleteOperation { + targetId: NodeId; + clock: number; } -export interface RemoteDeleteOperation { +export interface RemoteCharDeleteOperation { targetId: NodeId; clock: number; } diff --git a/client/src/apis/useSocket.ts b/client/src/apis/useSocket.ts index b5281a30..d4dd0e01 100644 --- a/client/src/apis/useSocket.ts +++ b/client/src/apis/useSocket.ts @@ -1,6 +1,8 @@ import { - RemoteInsertOperation, - RemoteDeleteOperation, + RemoteBlockInsertOperation, + RemoteBlockDeleteOperation, + RemoteCharInsertOperation, + RemoteCharDeleteOperation, CursorPosition, SerializedProps, } from "@noctaCrdt/Interfaces"; @@ -9,16 +11,18 @@ import { useEffect, useRef } from "react"; import { io, Socket } from "socket.io-client"; // 구독 핸들러들의 타입 정의 interface RemoteOperationHandlers { - onRemoteInsert: (operation: RemoteInsertOperation) => void; - onRemoteDelete: (operation: RemoteDeleteOperation) => void; + onRemoteBlockInsert: (operation: RemoteBlockInsertOperation) => void; + onRemoteBlockDelete: (operation: RemoteBlockDeleteOperation) => void; + onRemoteCharInsert: (operation: RemoteCharInsertOperation) => void; + onRemoteCharDelete: (operation: RemoteCharDeleteOperation) => void; onRemoteCursor: (position: CursorPosition) => void; } // 훅의 반환 타입을 명시적으로 정의 interface UseSocketReturn { socket: Socket | null; - sendInsertOperation: (operation: RemoteInsertOperation) => void; - sendDeleteOperation: (operation: RemoteDeleteOperation) => void; + sendInsertOperation: (operation: RemoteBlockInsertOperation | RemoteCharInsertOperation) => void; + sendDeleteOperation: (operation: RemoteBlockDeleteOperation | RemoteCharDeleteOperation) => void; sendCursorPosition: (position: CursorPosition) => void; subscribeToRemoteOperations: (handlers: RemoteOperationHandlers) => (() => void) | undefined; } @@ -67,12 +71,20 @@ export const useSocket = (): UseSocketReturn => { }; }, []); - const sendInsertOperation = (operation: RemoteInsertOperation) => { - socketRef.current?.emit("insert", operation); + const sendInsertOperation = ( + operation: RemoteBlockInsertOperation | RemoteCharInsertOperation, + ) => { + if (operation.node instanceof Block) { + socketRef.current?.emit("insert/block", operation); + } else { + socketRef.current?.emit("insert/char", operation); + } console.log(operation); }; - const sendDeleteOperation = (operation: RemoteDeleteOperation) => { + const sendDeleteOperation = ( + operation: RemoteBlockDeleteOperation | RemoteCharDeleteOperation, + ) => { socketRef.current?.emit("delete", operation); }; @@ -81,19 +93,25 @@ export const useSocket = (): UseSocketReturn => { }; const subscribeToRemoteOperations = ({ - onRemoteInsert, - onRemoteDelete, + onRemoteBlockInsert, + onRemoteBlockDelete, + onRemoteCharInsert, + onRemoteCharDelete, onRemoteCursor, }: RemoteOperationHandlers) => { if (!socketRef.current) return; - socketRef.current.on("insert", onRemoteInsert); - socketRef.current.on("delete", onRemoteDelete); + socketRef.current.on("insert/block", onRemoteBlockInsert); + socketRef.current.on("delete/block", onRemoteBlockDelete); + socketRef.current.on("insert/char", onRemoteCharInsert); + socketRef.current.on("delete/char", onRemoteCharDelete); socketRef.current.on("cursor", onRemoteCursor); return () => { - socketRef.current?.off("insert", onRemoteInsert); - socketRef.current?.off("delete", onRemoteDelete); + socketRef.current?.off("insert/block", onRemoteBlockInsert); + socketRef.current?.off("delete/block", onRemoteBlockDelete); + socketRef.current?.off("insert/char", onRemoteCharInsert); + socketRef.current?.off("delete/char", onRemoteCharDelete); socketRef.current?.off("cursor", onRemoteCursor); }; }; diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index 3659466b..fc51a481 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -4,6 +4,7 @@ import { EditorCRDT } from "@noctaCrdt/Crdt"; import { BlockLinkedList } from "@noctaCrdt/LinkedList"; import { Block as CRDTBlock } from "@noctaCrdt/Node"; import { BlockId } from "@noctaCrdt/NodeId"; +import { RemoteCharInsertOperation } from "node_modules/@noctaCrdt/Interfaces.ts"; import { useRef, useState, useCallback, useEffect } from "react"; import { useSocket } from "@src/apis/useSocket.ts"; import { editorContainer, editorTitleContainer, editorTitle } from "./Editor.style"; @@ -23,7 +24,7 @@ export interface EditorStateProps { export const Editor = ({ onTitleChange }: EditorProps) => { const editorCRDT = useRef(new EditorCRDT(0)); - const { sendInsertOperation, sendDeleteOperation } = useSocket(); + const { sendInsertOperation, sendDeleteOperation, subscribeToRemoteOperations } = useSocket(); const [editorState, setEditorState] = useState({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, @@ -71,41 +72,37 @@ export const Editor = ({ onTitleChange }: EditorProps) => { }; const handleBlockInput = useCallback( - (e: React.FormEvent, blockId: BlockId) => { - const block = editorState.linkedList.getNode(blockId); + (e: React.FormEvent, block: CRDTBlock) => { if (!block) return; - let testNode; + + let operationNode; const element = e.currentTarget; const newContent = element.textContent || ""; const currentContent = block.crdt.read(); const selection = window.getSelection(); const caretPosition = selection?.focusOffset || 0; - if (newContent.length > currentContent.length) { + // 문자가 추가된 경우 if (caretPosition === 0) { const [addedChar] = newContent; - testNode = block.crdt.localInsert(0, addedChar); + operationNode = block.crdt.localInsert(0, addedChar); block.crdt.currentCaret = 1; } else if (caretPosition > currentContent.length) { const addedChar = newContent[newContent.length - 1]; - testNode = block.crdt.localInsert(currentContent.length, addedChar); + operationNode = block.crdt.localInsert(currentContent.length, addedChar); block.crdt.currentCaret = caretPosition; } else { const addedChar = newContent[caretPosition - 1]; - testNode = block.crdt.localInsert(caretPosition - 1, addedChar); + operationNode = block.crdt.localInsert(caretPosition - 1, addedChar); block.crdt.currentCaret = caretPosition; } - sendInsertOperation(testNode); + console.log("여기여", operationNode); + sendInsertOperation(operationNode); } else if (newContent.length < currentContent.length) { - if (caretPosition === 0) { - testNode = block.crdt.localDelete(0); - block.crdt.currentCaret = 0; - } else { - testNode = block.crdt.localDelete(caretPosition); - block.crdt.currentCaret = caretPosition; - } - console.log(testNode); - sendDeleteOperation(testNode); + // 문자가 삭제된 경우 + operationNode = block.crdt.localDelete(caretPosition); + block.crdt.currentCaret = caretPosition; + sendDeleteOperation(operationNode); } setEditorState((prev) => ({ @@ -114,7 +111,7 @@ export const Editor = ({ onTitleChange }: EditorProps) => { currentBlock: prev.currentBlock, })); }, - [editorState.linkedList], + [editorState.linkedList, sendInsertOperation, sendDeleteOperation], ); useEffect(() => { @@ -129,6 +126,74 @@ export const Editor = ({ onTitleChange }: EditorProps) => { }); }, []); + const subscriptionRef = useRef(false); + + useEffect(() => { + if (subscriptionRef.current) return; + subscriptionRef.current = true; + + const unsubscribe = subscribeToRemoteOperations({ + onRemoteBlockInsert: (operation) => { + console.log(operation, "block : 입력 확인합니다이"); + if (!editorCRDT.current) return; + editorCRDT.current.remoteInsert(operation); + setEditorState((prev) => ({ + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, + currentBlock: prev.currentBlock, + })); + }, + + onRemoteBlockDelete: (operation) => { + console.log(operation, "block : 삭제 확인합니다이"); + if (!editorCRDT.current) return; + editorCRDT.current.remoteDelete(operation); + setEditorState((prev) => ({ + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, + currentBlock: prev.currentBlock, + })); + }, + + onRemoteCharInsert: (operation) => { + // 변경되는건 char + console.log(operation, "char : 입력 확인합니다이"); + if (!editorCRDT.current) return; + const insertOperation: RemoteCharInsertOperation = { + node: operation.node, + blockId: operation.blockId, + }; + // 여기 ㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇ + + editorCRDT.current.remoteInsert(insertOperation); + setEditorState((prev) => ({ + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, + currentBlock: prev.currentBlock, + })); + }, + + onRemoteCharDelete: (operation) => { + console.log(operation, "char : 삭제 확인합니다이"); + if (!editorCRDT.current) return; + editorCRDT.current.remoteDelete(operation); + setEditorState((prev) => ({ + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, + currentBlock: prev.currentBlock, + })); + }, + onRemoteCursor: (position) => { + console.log(position); + }, + }); + + return () => { + subscriptionRef.current = false; + unsubscribe?.(); + }; + }, []); + console.log("block list", editorState.linkedList.spread()); return ( diff --git a/server/src/crdt/crdt.gateway.ts b/server/src/crdt/crdt.gateway.ts index d47a503d..5d7c5fa3 100644 --- a/server/src/crdt/crdt.gateway.ts +++ b/server/src/crdt/crdt.gateway.ts @@ -11,12 +11,15 @@ import { import { Socket, Server } from "socket.io"; import { CrdtService } from "./crdt.service"; import { - RemoteInsertOperation, - RemoteDeleteOperation, + RemoteBlockDeleteOperation, + RemoteCharDeleteOperation, + RemoteBlockInsertOperation, + RemoteCharInsertOperation, CursorPosition, } from "@noctaCrdt/Interfaces"; import { Logger } from "@nestjs/common"; import { NodeId } from "@noctaCrdt/NodeId"; +import { Block, Char } from "@noctaCrdt/Node"; // 클라이언트 맵 타입 정의 interface ClientInfo { @@ -105,11 +108,11 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } /** - * 삽입 연산 처리 + * 블록 삽입 연산 처리 */ - @SubscribeMessage("insert") - async handleInsert( - @MessageBody() data: RemoteInsertOperation, + @SubscribeMessage("insert/block") + async handleBlockInsert( + @MessageBody() data: RemoteBlockInsertOperation, @ConnectedSocket() client: Socket, ): Promise { const clientInfo = this.clientMap.get(client.id); @@ -118,13 +121,49 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa `Insert 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), ); + // 클라이언트의 char 변경을 보고 char변경이 일어난 block정보를 나머지 client에게 broadcast한다. - // CRDT 연산 처리 await this.crdtService.handleInsert(data); + console.log("블럭입니다", data); + const block = this.crdtService.getCRDT().LinkedList.getNode(data.node.id); // 변경이 일어난 block + client.broadcast.emit("insert/block", { + operation: data, + node: block, + timestamp: new Date().toISOString(), + sourceClientId: clientInfo?.clientId, + }); + } catch (error) { + this.logger.error( + `Insert 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, + error.stack, + ); + throw new WsException(`Insert 연산 실패: ${error.message}`); + } + } - // 다른 클라이언트들에게 연산 브로드캐스트 - client.broadcast.emit("insert", { - ...data, + /** + * 블록 삽입 연산 처리 + */ + @SubscribeMessage("insert/char") + async handleCharInsert( + @MessageBody() data: RemoteCharInsertOperation, + @ConnectedSocket() client: Socket, + ): Promise { + const clientInfo = this.clientMap.get(client.id); + try { + this.logger.debug( + `Insert 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, + JSON.stringify(data), + ); + + await this.crdtService.handleInsert(data); + console.log("char:", data); + const char = this.crdtService.getCRDT().LinkedList.getNode(data.node.id); // 변경이 일어난 block + + client.broadcast.emit("insert/char", { + operation: data, + node: char, + // block: block, // TODO : char는 BlockID를 보내야한다? Block을 보내야한다? 고민예정. timestamp: new Date().toISOString(), sourceClientId: clientInfo?.clientId, }); @@ -140,9 +179,41 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa /** * 삭제 연산 처리 */ - @SubscribeMessage("delete") - async handleDelete( - @MessageBody() data: RemoteDeleteOperation, + @SubscribeMessage("delete/block") + async handleBlockDelete( + @MessageBody() data: RemoteBlockDeleteOperation, + @ConnectedSocket() client: Socket, + ): Promise { + const clientInfo = this.clientMap.get(client.id); + try { + this.logger.debug( + `Delete 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, + JSON.stringify(data), + ); + + const deleteNode = new NodeId(data.clock, data.targetId.client); + await this.crdtService.handleDelete({ targetId: deleteNode, clock: data.clock }); + + client.broadcast.emit("delete", { + ...data, + timestamp: new Date().toISOString(), + sourceClientId: clientInfo?.clientId, + }); + } catch (error) { + this.logger.error( + `Delete 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, + error.stack, + ); + throw new WsException(`Delete 연산 실패: ${error.message}`); + } + } + + /** + * 삭제 연산 처리 + */ + @SubscribeMessage("delete/char") + async handleCharDelete( + @MessageBody() data: RemoteCharDeleteOperation, @ConnectedSocket() client: Socket, ): Promise { const clientInfo = this.clientMap.get(client.id); diff --git a/server/src/crdt/crdt.service.ts b/server/src/crdt/crdt.service.ts index 88251782..653d3c0a 100644 --- a/server/src/crdt/crdt.service.ts +++ b/server/src/crdt/crdt.service.ts @@ -3,7 +3,13 @@ import { InjectModel } from "@nestjs/mongoose"; import { Doc, DocumentDocument } from "./schemas/document.schema"; import { Model } from "mongoose"; import { BlockCRDT } from "@noctaCrdt/Crdt"; -import { RemoteInsertOperation, RemoteDeleteOperation } from "@noctaCrdt/Interfaces"; +import { + RemoteBlockDeleteOperation, + RemoteCharDeleteOperation, + RemoteBlockInsertOperation, + RemoteCharInsertOperation, +} from "@noctaCrdt/Interfaces"; + import { CharId } from "@noctaCrdt/NodeId"; import { Char } from "@noctaCrdt/Node"; @@ -76,12 +82,16 @@ export class CrdtService implements OnModuleInit { return doc; } - async handleInsert(operation: RemoteInsertOperation): Promise { + async handleInsert( + operation: RemoteBlockInsertOperation | RemoteCharInsertOperation, + ): Promise { this.crdt.remoteInsert(operation); await this.updateDocument(); } - async handleDelete(operation: RemoteDeleteOperation): Promise { + async handleDelete( + operation: RemoteBlockDeleteOperation | RemoteCharDeleteOperation, + ): Promise { this.crdt.remoteDelete(operation); await this.updateDocument(); } From a2d49a35e61d88546100c4a077b69d21b6f11a13 Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Tue, 19 Nov 2024 00:32:09 +0900 Subject: [PATCH 07/10] =?UTF-8?q?refactor:=20block.id=EC=97=90=EC=84=9C=20?= =?UTF-8?q?block=EC=9D=84=20=EC=A7=81=EC=A0=91=20=EC=A3=BC=EA=B3=A0=20?= =?UTF-8?q?=EB=B0=9B=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #109 --- client/src/features/editor/components/block/Block.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index 4f80085d..dafe1c56 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -12,7 +12,7 @@ interface BlockProps { id: string; block: CRDTBlock; isActive: boolean; - onInput: (e: React.FormEvent, blockId: BlockId) => void; + onInput: (e: React.FormEvent, block: CRDTBlock) => void; onKeyDown: (e: React.KeyboardEvent) => void; onClick: (blockId: BlockId, e: React.MouseEvent) => void; } @@ -31,7 +31,7 @@ export const Block: React.FC = memo( }); const handleInput = (e: React.FormEvent) => { - onInput(e, block.id); + onInput(e, block); }; const setFocusAndCursor = () => { From 5485dc9e19e2f012c4cd5cbcb257c2fa6711600c Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Tue, 19 Nov 2024 16:33:08 +0900 Subject: [PATCH 08/10] =?UTF-8?q?feat:=20Block,=20Char=20=EB=B3=84=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=B6=84=EB=A5=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Char, Block 으로 나누어 관리가 용이하도록 수정 Co-authored-by: Yeonkyu Min Co-authored-by: minjungw00 --- @noctaCrdt/Crdt.ts | 161 ++++++++++++++++++++++++++------------- @noctaCrdt/Interfaces.ts | 14 ++-- 2 files changed, 119 insertions(+), 56 deletions(-) diff --git a/@noctaCrdt/Crdt.ts b/@noctaCrdt/Crdt.ts index 535804c0..3f0f5a72 100644 --- a/@noctaCrdt/Crdt.ts +++ b/@noctaCrdt/Crdt.ts @@ -21,24 +21,50 @@ export class CRDT> { this.LinkedList = new LinkedList(); } - localInsert( - index: number, - value: string, - ): RemoteBlockInsertOperation | RemoteCharInsertOperation { - if (this instanceof BlockCRDT) { - const id = new CharId(this.clock + 1, this.client); - const remoteInsertion = this.LinkedList.insertAtIndex(index, value, id); - this.clock += 1; - return { node: remoteInsertion.node, blockId: BlockId } as RemoteBlockInsertOperation; - } else { - const id = new BlockId(this.clock + 1, this.client); - const remoteInsertion = this.LinkedList.insertAtIndex(index, value, id); - this.clock += 1; - return { node: remoteInsertion.node } as RemoteCharInsertOperation; - } + localInsert(index: number, value: string, blockId?: BlockId) {} + + localDelete(index: number, blockId?: BlockId) {} + + remoteInsert(operation: RemoteBlockInsertOperation | RemoteCharInsertOperation) {} + + remoteDelete(operation: RemoteBlockDeleteOperation | RemoteCharDeleteOperation) {} + + read(): string { + return this.LinkedList.stringify(); + } + + spread(): T[] { + return this.LinkedList.spread(); + } + + serialize(): SerializedProps { + return { + clock: this.clock, + client: this.client, + LinkedList: { + head: this.LinkedList.head, + nodeMap: this.LinkedList.nodeMap || {}, + }, + }; } +} + +export class EditorCRDT extends CRDT { + currentBlock: Block | null; - localDelete(index: number): RemoteBlockDeleteOperation | RemoteCharDeleteOperation { + constructor(client: number) { + super(client); + this.currentBlock = null; + } + + localInsert(index: number, value: string): RemoteBlockInsertOperation { + const id = new BlockId(this.clock + 1, this.client); + const remoteInsertion = this.LinkedList.insertAtIndex(index, value, id); + this.clock += 1; + return { node: remoteInsertion.node } as RemoteBlockInsertOperation; + } + + localDelete(index: number): RemoteBlockDeleteOperation { if (index < 0 || index >= this.LinkedList.spread().length) { throw new Error(`Invalid index: ${index}`); } @@ -48,7 +74,7 @@ export class CRDT> { throw new Error(`Node not found at index: ${index}`); } - const operation: RemoteBlockDeleteOperation | RemoteCharDeleteOperation = { + const operation: RemoteBlockDeleteOperation = { targetId: nodeToDelete.id, clock: this.clock + 1, }; @@ -59,13 +85,15 @@ export class CRDT> { return operation; } - remoteInsert(operation: RemoteBlockInsertOperation | RemoteCharInsertOperation): void { - const NodeIdClass = this instanceof BlockCRDT ? CharId : BlockId; - const NodeClass = this instanceof BlockCRDT ? Char : Block; + remoteUpdate(block: Block) { + this.LinkedList.nodeMap[JSON.stringify(block.id)] = block; + return { remoteUpdateOperation: block }; + } - const newNodeId = new NodeIdClass(operation.node.id.clock, operation.node.id.client); + remoteInsert(operation: RemoteBlockInsertOperation): void { + const newNodeId = new BlockId(operation.node.id.clock, operation.node.id.client); + const newNode = new Block(operation.node.value, newNodeId); - const newNode = new NodeClass(operation.node.value, newNodeId) as T; newNode.next = operation.node.next; newNode.prev = operation.node.prev; @@ -76,7 +104,7 @@ export class CRDT> { } } - remoteDelete(operation: RemoteBlockDeleteOperation | RemoteCharDeleteOperation): void { + remoteDelete(operation: RemoteBlockDeleteOperation): void { const { targetId, clock } = operation; if (targetId) { this.LinkedList.deleteNode(targetId); @@ -87,9 +115,9 @@ export class CRDT> { } localReorder(params: { - targetId: NodeId; - beforeId: NodeId | null; - afterId: NodeId | null; + targetId: BlockId; + beforeId: BlockId | null; + afterId: BlockId | null; }): RemoteReorderOperation { const operation: RemoteReorderOperation = { ...params, @@ -116,41 +144,72 @@ export class CRDT> { this.clock = clock + 1; } } +} - read(): string { - return this.LinkedList.stringify(); - } +export class BlockCRDT extends CRDT { + currentCaret: number; - spread(): T[] { - return this.LinkedList.spread(); + constructor(client: number) { + super(client); + this.currentCaret = 0; } - serialize(): SerializedProps { - return { - clock: this.clock, - client: this.client, - LinkedList: { - head: this.LinkedList.head, - nodeMap: this.LinkedList.nodeMap || {}, - }, + localInsert(index: number, value: string, blockId: BlockId): RemoteCharInsertOperation { + const id = new CharId(this.clock + 1, this.client); + const { node } = this.LinkedList.insertAtIndex(index, value, id); + this.clock += 1; + const operation: RemoteCharInsertOperation = { + node, + blockId, }; + + return operation; } -} -export class EditorCRDT extends CRDT { - currentBlock: Block | null; + localDelete(index: number, blockId: BlockId): RemoteCharDeleteOperation { + if (index < 0 || index >= this.LinkedList.spread().length) { + throw new Error(`Invalid index: ${index}`); + } - constructor(client: number) { - super(client); - this.currentBlock = null; + const nodeToDelete = this.LinkedList.findByIndex(index); + if (!nodeToDelete) { + throw new Error(`Node not found at index: ${index}`); + } + + const operation: RemoteCharDeleteOperation = { + targetId: nodeToDelete.id, + clock: this.clock + 1, + blockId, + }; + + this.LinkedList.deleteNode(nodeToDelete.id); + this.clock += 1; + + return operation; } -} -export class BlockCRDT extends CRDT { - currentCaret: number; + remoteInsert(operation: RemoteCharInsertOperation): void { + const newNodeId = new CharId(operation.node.id.clock, operation.node.id.client); + const newNode = new Char(operation.node.value, newNodeId); - constructor(client: number) { - super(client); - this.currentCaret = 0; + newNode.next = operation.node.next; + newNode.prev = operation.node.prev; + + this.LinkedList.insertById(newNode); + + if (this.clock <= newNode.id.clock) { + this.clock = newNode.id.clock + 1; + } + } + + remoteDelete(operation: RemoteCharDeleteOperation): void { + const { targetId, clock } = operation; + if (targetId) { + const targetNodeId = new CharId(operation.targetId.clock, operation.targetId.client); + this.LinkedList.deleteNode(targetNodeId); + } + if (this.clock <= clock) { + this.clock = clock + 1; + } } } diff --git a/@noctaCrdt/Interfaces.ts b/@noctaCrdt/Interfaces.ts index 18b36a52..13e1dfd4 100644 --- a/@noctaCrdt/Interfaces.ts +++ b/@noctaCrdt/Interfaces.ts @@ -12,6 +12,9 @@ export interface DeleteOperation { clock: number; } +export interface RemoteBlockUpdateOperation { + node: Block; +} export interface RemoteBlockInsertOperation { node: Block; } @@ -22,13 +25,14 @@ export interface RemoteCharInsertOperation { } export interface RemoteBlockDeleteOperation { - targetId: NodeId; + targetId: BlockId; clock: number; } export interface RemoteCharDeleteOperation { - targetId: NodeId; + targetId: CharId; clock: number; + blockId?: BlockId; } export interface CursorPosition { @@ -53,9 +57,9 @@ export interface ReorderNodesProps { } export interface RemoteReorderOperation { - targetId: NodeId; - beforeId: NodeId | null; - afterId: NodeId | null; + targetId: BlockId; + beforeId: BlockId | null; + afterId: BlockId | null; clock: number; client: number; } From c59acb0281cc6b48aeab32708997ad0044ac9eaa Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Tue, 19 Nov 2024 16:34:47 +0900 Subject: [PATCH 09/10] =?UTF-8?q?feat:=20=EC=88=98=EC=A0=95=EB=90=9C=20CRD?= =?UTF-8?q?T=EC=97=90=20=EB=A7=9E=EA=B2=8C=20remote/insert,delete=20?= =?UTF-8?q?=EC=97=B0=EC=82=B0=20=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - crdt.gateway 서버 코드 간략히 수정 - Char 정보 보낼때 BlockId 도 보내도록 수정 (클라이언트에서 전송한 정보로, 바로 수정된 Block을 알도록) - 수정된 CRDT에 맞게 MarkdownGrammer 파일 수정 - 각 BlockInsert, CharInsert .. 등에 맞게 Editor에 의존성 및 비지니스 로직 수정 #109 --- client/src/apis/useSocket.ts | 50 ++++++++++++------- client/src/features/editor/Editor.tsx | 47 +++++++++-------- .../editor/hooks/useMarkdownGrammer.ts | 12 +++-- server/src/crdt/crdt.gateway.ts | 12 +++-- 4 files changed, 74 insertions(+), 47 deletions(-) diff --git a/client/src/apis/useSocket.ts b/client/src/apis/useSocket.ts index d4dd0e01..d7fc5ccd 100644 --- a/client/src/apis/useSocket.ts +++ b/client/src/apis/useSocket.ts @@ -3,6 +3,7 @@ import { RemoteBlockDeleteOperation, RemoteCharInsertOperation, RemoteCharDeleteOperation, + RemoteBlockUpdateOperation, CursorPosition, SerializedProps, } from "@noctaCrdt/Interfaces"; @@ -11,6 +12,7 @@ import { useEffect, useRef } from "react"; import { io, Socket } from "socket.io-client"; // 구독 핸들러들의 타입 정의 interface RemoteOperationHandlers { + onRemoteBlockUpdate: (operation: RemoteBlockUpdateOperation) => void; onRemoteBlockInsert: (operation: RemoteBlockInsertOperation) => void; onRemoteBlockDelete: (operation: RemoteBlockDeleteOperation) => void; onRemoteCharInsert: (operation: RemoteCharInsertOperation) => void; @@ -21,8 +23,11 @@ interface RemoteOperationHandlers { // 훅의 반환 타입을 명시적으로 정의 interface UseSocketReturn { socket: Socket | null; - sendInsertOperation: (operation: RemoteBlockInsertOperation | RemoteCharInsertOperation) => void; - sendDeleteOperation: (operation: RemoteBlockDeleteOperation | RemoteCharDeleteOperation) => void; + sendBlockUpdateOperation: (operation: RemoteBlockUpdateOperation) => void; + sendBlockInsertOperation: (operation: RemoteBlockInsertOperation) => void; + sendCharInsertOperation: (operation: RemoteCharInsertOperation) => void; + sendBlockDeleteOperation: (operation: RemoteBlockDeleteOperation) => void; + sendCharDeleteOperation: (operation: RemoteCharDeleteOperation) => void; sendCursorPosition: (position: CursorPosition) => void; subscribeToRemoteOperations: (handlers: RemoteOperationHandlers) => (() => void) | undefined; } @@ -71,21 +76,27 @@ export const useSocket = (): UseSocketReturn => { }; }, []); - const sendInsertOperation = ( - operation: RemoteBlockInsertOperation | RemoteCharInsertOperation, - ) => { - if (operation.node instanceof Block) { - socketRef.current?.emit("insert/block", operation); - } else { - socketRef.current?.emit("insert/char", operation); - } + const sendBlockInsertOperation = (operation: RemoteBlockInsertOperation) => { + socketRef.current?.emit("insert/block", operation); console.log(operation); }; - const sendDeleteOperation = ( - operation: RemoteBlockDeleteOperation | RemoteCharDeleteOperation, - ) => { - socketRef.current?.emit("delete", operation); + const sendCharInsertOperation = (operation: RemoteCharInsertOperation) => { + socketRef.current?.emit("insert/char", operation); + + console.log(operation); + }; + + const sendBlockUpdateOperation = (operation: RemoteBlockUpdateOperation) => { + socketRef.current?.emit("update/block", operation); + }; + + const sendBlockDeleteOperation = (operation: RemoteBlockDeleteOperation) => { + socketRef.current?.emit("delete/block", operation); + }; + + const sendCharDeleteOperation = (operation: RemoteCharDeleteOperation) => { + socketRef.current?.emit("delete/char", operation); }; const sendCursorPosition = (position: CursorPosition) => { @@ -93,6 +104,7 @@ export const useSocket = (): UseSocketReturn => { }; const subscribeToRemoteOperations = ({ + onRemoteBlockUpdate, onRemoteBlockInsert, onRemoteBlockDelete, onRemoteCharInsert, @@ -100,7 +112,7 @@ export const useSocket = (): UseSocketReturn => { onRemoteCursor, }: RemoteOperationHandlers) => { if (!socketRef.current) return; - + socketRef.current.on("update/block", onRemoteBlockUpdate); socketRef.current.on("insert/block", onRemoteBlockInsert); socketRef.current.on("delete/block", onRemoteBlockDelete); socketRef.current.on("insert/char", onRemoteCharInsert); @@ -108,6 +120,7 @@ export const useSocket = (): UseSocketReturn => { socketRef.current.on("cursor", onRemoteCursor); return () => { + socketRef.current?.off("update/block", onRemoteBlockUpdate); socketRef.current?.off("insert/block", onRemoteBlockInsert); socketRef.current?.off("delete/block", onRemoteBlockDelete); socketRef.current?.off("insert/char", onRemoteCharInsert); @@ -118,8 +131,11 @@ export const useSocket = (): UseSocketReturn => { return { socket: socketRef.current, - sendInsertOperation, - sendDeleteOperation, + sendBlockUpdateOperation, + sendBlockInsertOperation, + sendCharInsertOperation, + sendBlockDeleteOperation, + sendCharDeleteOperation, sendCursorPosition, subscribeToRemoteOperations, }; diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index fc51a481..f121ba05 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -24,7 +24,8 @@ export interface EditorStateProps { export const Editor = ({ onTitleChange }: EditorProps) => { const editorCRDT = useRef(new EditorCRDT(0)); - const { sendInsertOperation, sendDeleteOperation, subscribeToRemoteOperations } = useSocket(); + const { sendCharInsertOperation, sendCharDeleteOperation, subscribeToRemoteOperations } = + useSocket(); const [editorState, setEditorState] = useState({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, @@ -81,28 +82,29 @@ export const Editor = ({ onTitleChange }: EditorProps) => { const currentContent = block.crdt.read(); const selection = window.getSelection(); const caretPosition = selection?.focusOffset || 0; + if (newContent.length > currentContent.length) { - // 문자가 추가된 경우 + let charNode: RemoteCharInsertOperation; if (caretPosition === 0) { const [addedChar] = newContent; - operationNode = block.crdt.localInsert(0, addedChar); + charNode = block.crdt.localInsert(0, addedChar, block.id); block.crdt.currentCaret = 1; } else if (caretPosition > currentContent.length) { const addedChar = newContent[newContent.length - 1]; - operationNode = block.crdt.localInsert(currentContent.length, addedChar); + charNode = block.crdt.localInsert(currentContent.length, addedChar, block.id); block.crdt.currentCaret = caretPosition; } else { const addedChar = newContent[caretPosition - 1]; - operationNode = block.crdt.localInsert(caretPosition - 1, addedChar); + charNode = block.crdt.localInsert(caretPosition - 1, addedChar, block.id); block.crdt.currentCaret = caretPosition; } - console.log("여기여", operationNode); - sendInsertOperation(operationNode); + sendCharInsertOperation({ node: charNode.node, blockId: block.id }); } else if (newContent.length < currentContent.length) { // 문자가 삭제된 경우 - operationNode = block.crdt.localDelete(caretPosition); + operationNode = block.crdt.localDelete(caretPosition, block.id); block.crdt.currentCaret = caretPosition; - sendDeleteOperation(operationNode); + console.log("로컬 삭제 연산 송신", operationNode); + sendCharDeleteOperation(operationNode); } setEditorState((prev) => ({ @@ -111,7 +113,7 @@ export const Editor = ({ onTitleChange }: EditorProps) => { currentBlock: prev.currentBlock, })); }, - [editorState.linkedList, sendInsertOperation, sendDeleteOperation], + [editorState.linkedList, sendCharInsertOperation, sendCharDeleteOperation], ); useEffect(() => { @@ -156,16 +158,10 @@ export const Editor = ({ onTitleChange }: EditorProps) => { }, onRemoteCharInsert: (operation) => { - // 변경되는건 char - console.log(operation, "char : 입력 확인합니다이"); if (!editorCRDT.current) return; - const insertOperation: RemoteCharInsertOperation = { - node: operation.node, - blockId: operation.blockId, - }; - // 여기 ㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇ - - editorCRDT.current.remoteInsert(insertOperation); + const targetBlock = + editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; + targetBlock.crdt.remoteInsert(operation); setEditorState((prev) => ({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, @@ -176,15 +172,22 @@ export const Editor = ({ onTitleChange }: EditorProps) => { onRemoteCharDelete: (operation) => { console.log(operation, "char : 삭제 확인합니다이"); if (!editorCRDT.current) return; - editorCRDT.current.remoteDelete(operation); + const targetBlock = + editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; + + targetBlock.crdt.remoteDelete({ targetId: operation.targetId, clock: operation.clock }); setEditorState((prev) => ({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, currentBlock: prev.currentBlock, })); }, + + onRemoteBlockUpdate: (operation) => { + console.log(operation, "새 블럭 업데이트 수신 "); + }, onRemoteCursor: (position) => { - console.log(position); + console.log(position, "커서위치 수신"); }, }); @@ -194,7 +197,7 @@ export const Editor = ({ onTitleChange }: EditorProps) => { }; }, []); - console.log("block list", editorState.linkedList.spread()); + // console.log("block list", editorState.linkedList.spread()); return (
diff --git a/client/src/features/editor/hooks/useMarkdownGrammer.ts b/client/src/features/editor/hooks/useMarkdownGrammer.ts index 8dd76c75..7bba51fd 100644 --- a/client/src/features/editor/hooks/useMarkdownGrammer.ts +++ b/client/src/features/editor/hooks/useMarkdownGrammer.ts @@ -75,7 +75,7 @@ export const useMarkdownGrammer = ({ if (afterText) { // 캐럿 이후의 텍스트만 제거 for (let i = currentContent.length - 1; i >= caretPosition; i--) { - currentBlock.crdt.localDelete(i); + currentBlock.crdt.localDelete(i, currentBlock.id); } } @@ -86,7 +86,7 @@ export const useMarkdownGrammer = ({ // 캐럿 이후의 텍스트 있으면 새 블록에 추가 if (afterText) { afterText.split("").forEach((char, i) => { - newBlock.crdt.localInsert(i, char); + newBlock.crdt.localInsert(i, char, newBlock.id); }); } @@ -94,6 +94,8 @@ export const useMarkdownGrammer = ({ if (["ul", "ol", "checkbox"].includes(currentBlock.type)) { newBlock.type = currentBlock.type; } + // !! TODO socket.update + updateEditorState(newBlock.id); break; } @@ -142,7 +144,7 @@ export const useMarkdownGrammer = ({ if (prevBlock) { const prevBlockEndCaret = prevBlock.crdt.read().length; currentContent.split("").forEach((char) => { - prevBlock.crdt.localInsert(prevBlock.crdt.read().length, char); + prevBlock.crdt.localInsert(prevBlock.crdt.read().length, char, prevBlock.id); }); prevBlock.crdt.currentCaret = prevBlockEndCaret; editorCRDT.localDelete(currentIndex); @@ -187,12 +189,14 @@ export const useMarkdownGrammer = ({ currentBlock.type = markdownElement.type; let deleteCount = 0; while (deleteCount < markdownElement.length) { - currentBlock.crdt.localDelete(0); + currentBlock.crdt.localDelete(0, currentBlock.id); deleteCount += 1; } + // !!TODO emit 송신 currentBlock.crdt.currentCaret = 0; updateEditorState(currentBlock.id); } + break; } diff --git a/server/src/crdt/crdt.gateway.ts b/server/src/crdt/crdt.gateway.ts index 5d7c5fa3..d84ebbd3 100644 --- a/server/src/crdt/crdt.gateway.ts +++ b/server/src/crdt/crdt.gateway.ts @@ -142,7 +142,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } /** - * 블록 삽입 연산 처리 + * 글자 삽입 연산 처리 */ @SubscribeMessage("insert/char") async handleCharInsert( @@ -159,11 +159,15 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa await this.crdtService.handleInsert(data); console.log("char:", data); const char = this.crdtService.getCRDT().LinkedList.getNode(data.node.id); // 변경이 일어난 block + // !! TODO 블록 찾기 + + // BlockCRDT + // server는 EditorCRDT 없습니다. - BlockCRDT 로 사용되고있음. client.broadcast.emit("insert/char", { operation: data, node: char, - // block: block, // TODO : char는 BlockID를 보내야한다? Block을 보내야한다? 고민예정. + blockId: data.blockId, // TODO : char는 BlockID를 보내야한다? Block을 보내야한다? 고민예정. timestamp: new Date().toISOString(), sourceClientId: clientInfo?.clientId, }); @@ -224,9 +228,9 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ); const deleteNode = new NodeId(data.clock, data.targetId.client); - await this.crdtService.handleDelete({ targetId: deleteNode, clock: data.clock }); + await this.crdtService.handleDelete({ targetId: deleteNode, clock: data.clock }); // 얘도안됨 - client.broadcast.emit("delete", { + client.broadcast.emit("delete/char", { ...data, timestamp: new Date().toISOString(), sourceClientId: clientInfo?.clientId, From ab7d6736c1ba2c609c8b1076635d4178eec6c324 Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Tue, 19 Nov 2024 16:36:36 +0900 Subject: [PATCH 10/10] =?UTF-8?q?chore:=20build=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - as 로 임시 방편 - 추후 Editor CRDT 서버에 도입할때 수정 예정 --- server/src/crdt/crdt.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/crdt/crdt.service.ts b/server/src/crdt/crdt.service.ts index 653d3c0a..a175812f 100644 --- a/server/src/crdt/crdt.service.ts +++ b/server/src/crdt/crdt.service.ts @@ -85,7 +85,7 @@ export class CrdtService implements OnModuleInit { async handleInsert( operation: RemoteBlockInsertOperation | RemoteCharInsertOperation, ): Promise { - this.crdt.remoteInsert(operation); + this.crdt.remoteInsert(operation as RemoteCharInsertOperation); await this.updateDocument(); }