From 70311dbbe58b0a019d9f8cb8fd8d7d98e69aad77 Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Sun, 24 Nov 2024 20:58:08 +0900 Subject: [PATCH 01/18] =?UTF-8?q?feat:=20socketStore=EC=97=90=20accessToke?= =?UTF-8?q?n=EC=9D=98=EC=A1=B4=EC=84=B1=20=EB=B6=80=EC=97=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - socket 생성때 auth에서 사용한 accessToken을 활용 - null 일경우 guestWorkspace를 표현할 예정 #181 --- client/src/App.tsx | 4 ++-- client/src/stores/useSocketStore.ts | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 8878f7b0..6ceda264 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -20,13 +20,13 @@ const App = () => { useEffect(() => { const socketStore = useSocketStore.getState(); - socketStore.init(); + socketStore.init(accessToken); return () => { setTimeout(() => { socketStore.cleanup(); }, 0); }; - }, []); + }, [accessToken]); return ( <> diff --git a/client/src/stores/useSocketStore.ts b/client/src/stores/useSocketStore.ts index ec5f8a03..0cb0b8ed 100644 --- a/client/src/stores/useSocketStore.ts +++ b/client/src/stores/useSocketStore.ts @@ -16,7 +16,7 @@ interface SocketStore { socket: Socket | null; clientId: number | null; workspace: WorkSpaceSerializedProps | null; - init: () => void; + init: (accessToken: string | null) => void; cleanup: () => void; fetchWorkspaceData: () => WorkSpaceSerializedProps | null; sendPageCreateOperation: (operation: RemotePageCreateOperation) => void; @@ -51,9 +51,8 @@ export const useSocketStore = create((set, get) => ({ clientId: null, workspace: null, - init: () => { + init: (accessToken: string | null) => { const { socket: existingSocket } = get(); - if (existingSocket?.connected) return; if (existingSocket) { existingSocket.disconnect(); @@ -68,6 +67,9 @@ export const useSocketStore = create((set, get) => ({ withCredentials: true, reconnectionAttempts: 5, reconnectionDelay: 1000, + auth: { + token: accessToken, + }, autoConnect: false, }); From 83454e72503eb1e80404785c57e3913ca4f3076f Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Sun, 24 Nov 2024 21:11:04 +0900 Subject: [PATCH 02/18] =?UTF-8?q?refactor:=20Map=EC=9C=BC=EB=A1=9C=20works?= =?UTF-8?q?pace=20=EA=B4=80=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 현재 상황에 불필요한 부분 주석처리 #183 --- server/src/crdt/crdt.service.ts | 238 +++++++++++++++++--------------- 1 file changed, 126 insertions(+), 112 deletions(-) diff --git a/server/src/crdt/crdt.service.ts b/server/src/crdt/crdt.service.ts index 3f452869..0bf428af 100644 --- a/server/src/crdt/crdt.service.ts +++ b/server/src/crdt/crdt.service.ts @@ -9,135 +9,149 @@ import { WorkSpaceSerializedProps } from "@noctaCrdt/Interfaces"; @Injectable() export class workSpaceService implements OnModuleInit { - private workspace: CRDTWorkSpace; + private workspaces: Map; private tempPage: CRDTPage; constructor(@InjectModel(Workspace.name) private workspaceModel: Model) { - this.tempPage = new CRDTPage(); - this.workspace = new CRDTWorkSpace("test", [this.tempPage]); + // this.tempPage = new CRDTPage(); + // this.workspaces = new CRDTWorkSpace("test", [this.tempPage]); } - async onModuleInit() { - try { - // MongoDB에서 Workspace 문서 가져오기 - const doc = await this.getDocument(); - if (doc) { - // 1. Workspace 기본 정보 복원 - this.workspace = new CRDTWorkSpace(doc.id, []); - this.workspace.deserialize({ - id: doc.id, - pageList: doc.pageList, - authUser: doc.authUser, - } as WorkSpaceSerializedProps); - } - console.log("init 이후 서버 인스턴스 확인", this.workspace); - } catch (error) { - console.error("Error during CrdtService initialization:", error); - throw error; - } + this.workspaces = new Map(); + // 게스트 워크스페이스 초기화 + const guestWorkspace = new CRDTWorkSpace("guest", []); + this.workspaces.set("guest", guestWorkspace); } - async getDocument(): Promise { - let doc = await this.workspaceModel.findOne(); - if (!doc) { - const serializedWorkspace = this.workspace.serialize(); - console.log("Serialized workspace:", serializedWorkspace); - - // Workspace 스키마에 맞게 데이터 구조화 - doc = new this.workspaceModel({ - id: serializedWorkspace.id || "default-id", // 적절한 ID 생성 필요 - pageList: serializedWorkspace.pageList.map((page) => ({ - id: page.id, - title: page.title, - icon: page.icon, - crdt: { - clock: page.crdt.clock, - client: page.crdt.client, - currentBlock: page.crdt.currentBlock, - LinkedList: { - head: page.crdt.LinkedList.head, - nodeMap: page.crdt.LinkedList.nodeMap, - }, - }, - })), - authUser: new Map(), // 필요한 경우 초기 인증 사용자 데이터 설정 - updatedAt: new Date(), - }); - - console.log("New document to save:", doc); - try { - await doc.save(); - console.log("Document saved successfully"); - } catch (error) { - console.error("Error saving document:", error); - throw error; - } + getWorkspace(userId: string): CRDTWorkSpace { + if (!this.workspaces.has(userId)) { + // 새로운 워크스페이스 생성 + const newWorkspace = new CRDTWorkSpace(userId, []); + this.workspaces.set(userId, newWorkspace); } - return doc; - } - async updateDocument(): Promise { - const serializedWorkspace = this.workspace.serialize(); - return await this.workspaceModel - .findOneAndUpdate( - {}, - { - $set: { - ...serializedWorkspace, - updatedAt: new Date(), - }, - }, - { new: true, upsert: true }, - ) - .exec(); + return this.workspaces.get(userId); } +} - // 각 레벨별 구체적인 Insert/Delete 처리 메서드들 - async handleWorkSpaceInsert(payload: any): Promise { - // WorkSpace 레벨 Insert 구현 - } +// async onModuleInit() { +// try { +// // MongoDB에서 Workspace 문서 가져오기 +// const doc = await this.getDocument(); +// if (doc) { +// // 1. Workspace 기본 정보 복원 +// this.workspace = new CRDTWorkSpace(doc.id, []); +// this.workspace.deserialize({ +// id: doc.id, +// pageList: doc.pageList, +// authUser: doc.authUser, +// } as WorkSpaceSerializedProps); +// } +// console.log("init 이후 서버 인스턴스 확인", this.workspace); +// } catch (error) { +// console.error("Error during CrdtService initialization:", error); +// throw error; +// } +// } +// async getDocument(): Promise { +// let doc = await this.workspaceModel.findOne(); +// if (!doc) { +// const serializedWorkspace = this.workspace.serialize(); +// console.log("Serialized workspace:", serializedWorkspace); + +// // Workspace 스키마에 맞게 데이터 구조화 +// doc = new this.workspaceModel({ +// id: serializedWorkspace.id || "default-id", // 적절한 ID 생성 필요 +// pageList: serializedWorkspace.pageList.map((page) => ({ +// id: page.id, +// title: page.title, +// icon: page.icon, +// crdt: { +// clock: page.crdt.clock, +// client: page.crdt.client, +// currentBlock: page.crdt.currentBlock, +// LinkedList: { +// head: page.crdt.LinkedList.head, +// nodeMap: page.crdt.LinkedList.nodeMap, +// }, +// }, +// })), +// authUser: new Map(), // 필요한 경우 초기 인증 사용자 데이터 설정 +// updatedAt: new Date(), +// }); + +// console.log("New document to save:", doc); +// try { +// await doc.save(); +// console.log("Document saved successfully"); +// } catch (error) { +// console.error("Error saving document:", error); +// throw error; +// } +// } +// return doc; +// } +// async updateDocument(): Promise { +// const serializedWorkspace = this.workspace.serialize(); +// return await this.workspaceModel +// .findOneAndUpdate( +// {}, +// { +// $set: { +// ...serializedWorkspace, +// updatedAt: new Date(), +// }, +// }, +// { new: true, upsert: true }, +// ) +// .exec(); +// } - async handleWorkSpaceDelete(payload: any): Promise { - // WorkSpace 레벨 Delete 구현 - } +// // 각 레벨별 구체적인 Insert/Delete 처리 메서드들 +// async handleWorkSpaceInsert(payload: any): Promise { +// // WorkSpace 레벨 Insert 구현 +// } - async handlePageInsert(payload: any): Promise { - // Page 레벨 Insert 구현 - // const newPage = await this.getWorkspace().getPage(payload).deserializePage(payload); - // this.workspace.pageList.push(newPage); - } +// async handleWorkSpaceDelete(payload: any): Promise { +// // WorkSpace 레벨 Delete 구현 +// } - async handlePageDelete(payload: any): Promise { - // Page 레벨 Delete 구현 - const pageIndex = this.workspace.pageList.findIndex((p) => p.id === payload.pageId); - if (pageIndex !== -1) { - this.workspace.pageList.splice(pageIndex, 1); - } - } +// async handlePageInsert(payload: any): Promise { +// // Page 레벨 Insert 구현 +// // const newPage = await this.getWorkspace().getPage(payload).deserializePage(payload); +// // this.workspace.pageList.push(newPage); +// } - async handleBlockInsert(editorCRDT: EditorCRDT, payload: any): Promise { - // Block 레벨 Insert 구현 - console.log(editorCRDT, payload, "???"); - editorCRDT.remoteInsert(payload); - } +// async handlePageDelete(payload: any): Promise { +// // Page 레벨 Delete 구현 +// const pageIndex = this.workspace.pageList.findIndex((p) => p.id === payload.pageId); +// if (pageIndex !== -1) { +// this.workspace.pageList.splice(pageIndex, 1); +// } +// } - async handleBlockDelete(editorCRDT: EditorCRDT, payload: any): Promise { - // Block 레벨 Delete 구현 - editorCRDT.remoteDelete(payload); - } +// async handleBlockInsert(editorCRDT: EditorCRDT, payload: any): Promise { +// // Block 레벨 Insert 구현 +// console.log(editorCRDT, payload, "???"); +// editorCRDT.remoteInsert(payload); +// } - async handleCharInsert(blockCRDT: BlockCRDT, payload: any): Promise { - // Char 레벨 Insert 구현 - blockCRDT.remoteInsert(payload); - } +// async handleBlockDelete(editorCRDT: EditorCRDT, payload: any): Promise { +// // Block 레벨 Delete 구현 +// editorCRDT.remoteDelete(payload); +// } - async handleCharDelete(blockCRDT: BlockCRDT, payload: any): Promise { - // Char 레벨 Delete 구현 - blockCRDT.remoteDelete(payload); - } +// async handleCharInsert(blockCRDT: BlockCRDT, payload: any): Promise { +// // Char 레벨 Insert 구현 +// blockCRDT.remoteInsert(payload); +// } - getWorkspace(): CRDTWorkSpace { - return this.workspace; - } -} +// async handleCharDelete(blockCRDT: BlockCRDT, payload: any): Promise { +// // Char 레벨 Delete 구현 +// blockCRDT.remoteDelete(payload); +// } + +// getWorkspace(): CRDTWorkSpace { +// return this.workspace; +// } // this.crdt = new EditorCRDT(0); // try { From 5ff0e2e9702b821c6832b262fa10329da44e6f11 Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Sun, 24 Nov 2024 21:11:55 +0900 Subject: [PATCH 03/18] =?UTF-8?q?feat:=20CRDT=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=9F=AC=EB=A6=AC=EC=97=90=20AuthModule=20import=20=EC=84=A0?= =?UTF-8?q?=EC=96=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #183 --- server/src/crdt/crdt.module.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/src/crdt/crdt.module.ts b/server/src/crdt/crdt.module.ts index 547696e5..c79c9616 100644 --- a/server/src/crdt/crdt.module.ts +++ b/server/src/crdt/crdt.module.ts @@ -3,9 +3,13 @@ import { workSpaceService } from "./crdt.service"; import { MongooseModule } from "@nestjs/mongoose"; import { Workspace, WorkspaceSchema } from "./schemas/workspace.schema"; import { CrdtGateway } from "./crdt.gateway"; +import { AuthModule } from "../auth/auth.module"; @Module({ - imports: [MongooseModule.forFeature([{ name: Workspace.name, schema: WorkspaceSchema }])], + imports: [ + AuthModule, + MongooseModule.forFeature([{ name: Workspace.name, schema: WorkspaceSchema }]), + ], providers: [workSpaceService, CrdtGateway], exports: [workSpaceService], }) From 72735c7ffa404dfa56fa4fb17ecbf8eb8f8b9b40 Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Sun, 24 Nov 2024 21:48:11 +0900 Subject: [PATCH 04/18] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EB=B3=84=EB=A1=9C=20client.join=20=ED=95=98=EC=97=AC=20socket?= =?UTF-8?q?=EC=9D=84=20=EC=86=A1=EC=8B=A0=EB=B0=9B=EB=8F=84=EB=A1=9D=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 #183 --- server/src/crdt/crdt.gateway.ts | 106 +++++++++++++++----------------- 1 file changed, 50 insertions(+), 56 deletions(-) diff --git a/server/src/crdt/crdt.gateway.ts b/server/src/crdt/crdt.gateway.ts index e4e6d9ad..94fcf97b 100644 --- a/server/src/crdt/crdt.gateway.ts +++ b/server/src/crdt/crdt.gateway.ts @@ -24,7 +24,7 @@ import { Logger } from "@nestjs/common"; import { nanoid } from "nanoid"; import { Page } from "@noctaCrdt/Page"; import { EditorCRDT } from "@noctaCrdt/Crdt"; - +import { JwtService } from "@nestjs/jwt"; // 클라이언트 맵 타입 정의 interface ClientInfo { clientId: number; @@ -47,10 +47,10 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa private server: Server; private clientIdCounter: number = 1; private clientMap: Map = new Map(); - private guestMap; - private guestIdCounter; - constructor(private readonly workSpaceService: workSpaceService) {} - + constructor( + private readonly workSpaceService: workSpaceService, + private readonly jwtService: JwtService, // JwtService 주입 + ) {} afterInit(server: Server) { this.server = server; } @@ -60,26 +60,35 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa */ async handleConnection(client: Socket) { try { + // 현재 토큰 처리를 어떻게 하는지 몰라 일단 null로 가정하고 guest환경을 만들겠음. + const token = client.handshake.auth.token; + let userId = null; + if (token) { + try { + const payload = this.jwtService.verify(token, { secret: process.env.JWT_SECRET }); + userId = payload.sub; + } catch (e) { + this.logger.warn("Invalid token, trating as guest"); + } + } + if (!userId) { + userId = "guest"; + } + client.data.userId = userId; + client.join(userId); + const currentWorkSpace = await this.workSpaceService.getWorkspace(userId).serialize(); + client.emit("workspace", currentWorkSpace); + const assignedId = (this.clientIdCounter += 1); const clientInfo: ClientInfo = { clientId: assignedId, connectionTime: new Date(), }; this.clientMap.set(client.id, clientInfo); - - // 클라이언트에게 ID 할당 + console.log(userId, "유저아이디 체크"); client.emit("assign/clientId", assignedId); - // 현재 문서 상태 전송 - const currentWorkSpace = await this.workSpaceService.getWorkspace().serialize(); - - console.log("mongoDB에서 받아온 다음의 상태 : ", currentWorkSpace); // clinet 0 clock 1 이미 저장되어있음 - // client의 인스턴스는 얘를 받잖아요 . clock 1 로 동기화가 돼야하는데 - // 동기화가 안돼서 0 인상태라서 - // 새로 입력하면 1, 1 충돌나는거죠. - client.emit("workspace", currentWorkSpace); client.broadcast.emit("userJoined", { clientId: assignedId }); - this.logger.log(`클라이언트 연결 성공 - Socket ID: ${client.id}, Client ID: ${assignedId}`); this.logger.debug(`현재 연결된 클라이언트 수: ${this.clientMap.size}`); } catch (error) { @@ -128,24 +137,21 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa `Page create 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), ); - // TODO 클라이언트로부터 받은 page 서버의 인스턴스에 저장한다. - // TODO: 워크스페이스 여러개일 때 처리 해야함 + const userId = client.data.userId; + const workspace = this.workSpaceService.getWorkspace(userId); - const currentWorkspace = this.workSpaceService.getWorkspace(); - // 여기서 page ID를 만들고 , 서버 인스턴스에 page 만들고, 클라이언트에 operation으로 전달 const newEditorCRDT = new EditorCRDT(data.clientId); const newPage = new Page(nanoid(), "새로운 페이지", "📄", newEditorCRDT); - // 서버 인스턴스에 page 추가 - currentWorkspace.pageList.push(newPage); + workspace.pageList.push(newPage); const operation = { workspaceId: data.workspaceId, clientId: data.clientId, page: newPage.serialize(), }; - // 클라이언트 인스턴스에 page 추가 client.emit("create/page", operation); - client.broadcast.emit("create/page", operation); + client.to(userId).emit("create/page", operation); + //client.broadcast.emit("create/page", operation); } catch (error) { this.logger.error( `Page Create 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -168,30 +174,21 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa `블록 Update 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), ); - // 1. 워크스페이스 가져오기 - const workspace = this.workSpaceService.getWorkspace(); - // delete할때 이 삭제되는 node의 클락을 +1하지말고 보내고 - // 그다음 client를 node를 보낸 다으멩 클락을 +1 을 하자 . - // server의 clock상태와 - // client의 clock상태를 계속 볼수있게 콘솔을 찍어놓고 - // 얘네가 생성될때 + const userId = client.data.userId; + const workspace = this.workSpaceService.getWorkspace(userId); - // 초기값은 client = client 0 clock 0 , server = clinet 0 clock 0 - // 여기서 입력이 발생하면 clinet 가 입력해야 clinet 0 clock 1, server = client0 clock 1 - // 2. 해당 페이지 가져오기 const currentPage = workspace.pageList.find((p) => p.id === data.pageId); if (!currentPage) { throw new Error(`Page with id ${data.pageId} not found`); } currentPage.crdt.remoteUpdate(data.node, data.pageId); - // 5. 다른 클라이언트들에게 업데이트된 블록 정보 브로드캐스트 const operation = { node: data.node, pageId: data.pageId, } as RemoteBlockUpdateOperation; - client.broadcast.emit("update/block", operation); + client.to(userId).emit("update/block", operation); } catch (error) { this.logger.error( `블록 Update 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -215,11 +212,10 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa `Insert 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), ); - // TODO 클라이언트로부터 받은 정보를 서버의 인스턴스에 저장한다. - // 몇번 page의 editorCRDT에 추가가 되냐 + const userId = client.data.userId; const currentPage = this.workSpaceService - .getWorkspace() + .getWorkspace(userId) .pageList.find((p) => p.id === data.pageId); if (!currentPage) { throw new Error(`Page with id ${data.pageId} not found`); @@ -230,7 +226,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa node: data.node, pageId: data.pageId, }; - client.broadcast.emit("insert/block", operation); + client.to(userId).emit("insert/block", operation); } catch (error) { this.logger.error( `Block Insert 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -254,12 +250,10 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa `Insert 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), ); - // blockId 는 수신 받음 - // 원하는 block에 char node 를 삽입해야함 이제. - // !! TODO 블록 찾기 + const userId = client.data.userId; const currentPage = this.workSpaceService - .getWorkspace() + .getWorkspace(userId) .pageList.find((p) => p.id === data.pageId); if (!currentPage) { throw new Error(`Page with id ${data.pageId} not found`); @@ -275,7 +269,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa node: data.node, blockId: data.blockId, }; - client.broadcast.emit("insert/char", operation); + client.to(userId).emit("insert/char", operation); } catch (error) { this.logger.error( `Char Insert 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -298,9 +292,9 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa `Delete 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), ); - + const userId = client.data.userId; const currentPage = this.workSpaceService - .getWorkspace() + .getWorkspace(userId) .pageList.find((p) => p.id === data.pageId); if (!currentPage) { throw new Error(`Page with id ${data.pageId} not found`); @@ -311,7 +305,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa clock: data.clock, pageId: data.pageId, }; - client.broadcast.emit("delete/block", operation); + client.to(userId).emit("delete/block", operation); } catch (error) { this.logger.error( `Block Delete 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -335,9 +329,9 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa `Delete 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), ); - + const userId = client.data.userId; const currentPage = this.workSpaceService - .getWorkspace() + .getWorkspace(userId) .pageList.find((p) => p.id === data.pageId); if (!currentPage) { throw new Error(`Page with id ${data.pageId} not found`); @@ -353,7 +347,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa clock: data.clock, blockId: data.blockId, }; - client.broadcast.emit("delete/char", operation); + client.to(userId).emit("delete/char", operation); } catch (error) { this.logger.error( `Char Delete 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -374,8 +368,8 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa `블록 Reorder 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), ); - // 1. 워크스페이스 가져오기 - const workspace = this.workSpaceService.getWorkspace(); + const userId = client.data.userId; + const workspace = this.workSpaceService.getWorkspace(userId); const currentPage = workspace.pageList.find((p) => p.id === data.pageId); if (!currentPage) { @@ -390,7 +384,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa afterId: data.afterId, pageId: data.pageId, } as RemoteBlockReorderOperation; - client.broadcast.emit("reorder/block", operation); + client.to(userId).emit("reorder/block", operation); } catch (error) { this.logger.error( `블록 Reorder 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -416,8 +410,8 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa clientId: clientInfo?.clientId, position: data.position, }; - // 커서 정보에 클라이언트 ID 추가하여 브로드캐스트 - client.broadcast.emit("cursor", operation); + const userId = client.data.userId; + client.to(userId).emit("cursor", operation); } catch (error) { this.logger.error( `Cursor 업데이트 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, From e31934dc6a5444520c7eafda8b8be4412faba1ad Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Sun, 24 Nov 2024 21:48:57 +0900 Subject: [PATCH 05/18] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EB=A1=9C=EA=B7=B8=20=EC=A0=9C=EA=B1=B0,=20import?= =?UTF-8?q?=20=EC=84=A0=EC=96=B8=EB=AC=B8=20=EC=9C=84=EC=B9=98=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 --- client/src/components/sidebar/Sidebar.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/src/components/sidebar/Sidebar.tsx b/client/src/components/sidebar/Sidebar.tsx index 57db7ad8..4659e0f6 100644 --- a/client/src/components/sidebar/Sidebar.tsx +++ b/client/src/components/sidebar/Sidebar.tsx @@ -1,4 +1,3 @@ -import { useIsSidebarOpen, useSidebarActions } from "@stores/useSidebarStore"; import { motion } from "framer-motion"; import { IconButton } from "@components/button/IconButton"; import { Modal } from "@components/modal/modal"; @@ -6,6 +5,7 @@ import { useModal } from "@components/modal/useModal"; import { MAX_VISIBLE_PAGE } from "@src/constants/page"; import { AuthButton } from "@src/features/auth/AuthButton"; import { Page } from "@src/types/page"; +import { useIsSidebarOpen, useSidebarActions } from "@stores/useSidebarStore"; import { MenuButton } from "./MenuButton"; import { PageItem } from "./PageItem"; import { animation, contentVariants, sidebarVariants } from "./Sidebar.animation"; @@ -22,7 +22,6 @@ export const Sidebar = ({ }) => { const visiblePages = pages.filter((page) => page.isVisible); const isMaxVisiblePage = visiblePages.length >= MAX_VISIBLE_PAGE; - console.log(pages, visiblePages, "체크용"); const isSidebarOpen = useIsSidebarOpen(); const { toggleSidebar } = useSidebarActions(); const { isOpen, openModal, closeModal } = useModal(); @@ -36,7 +35,6 @@ export const Sidebar = ({ }; const handleAddPageButtonClick = () => { - console.log(isMaxVisiblePage, "체크"); if (isMaxVisiblePage) { openModal(); return; From df45a85795779c88085f2c6876db3b0fc0333d51 Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Sun, 24 Nov 2024 21:49:13 +0900 Subject: [PATCH 06/18] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=84=A0=EC=96=B8=EB=AC=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/crdt/crdt.service.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/server/src/crdt/crdt.service.ts b/server/src/crdt/crdt.service.ts index 0bf428af..a0dc44e7 100644 --- a/server/src/crdt/crdt.service.ts +++ b/server/src/crdt/crdt.service.ts @@ -10,11 +10,7 @@ import { WorkSpaceSerializedProps } from "@noctaCrdt/Interfaces"; @Injectable() export class workSpaceService implements OnModuleInit { private workspaces: Map; - private tempPage: CRDTPage; - constructor(@InjectModel(Workspace.name) private workspaceModel: Model) { - // this.tempPage = new CRDTPage(); - // this.workspaces = new CRDTWorkSpace("test", [this.tempPage]); - } + constructor(@InjectModel(Workspace.name) private workspaceModel: Model) {} async onModuleInit() { this.workspaces = new Map(); // 게스트 워크스페이스 초기화 From b00ed2bbf4cbb27db90e0e54978208a9503d9fd0 Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Mon, 25 Nov 2024 17:21:02 +0900 Subject: [PATCH 07/18] =?UTF-8?q?refactor:=20accessToken=20=EC=9D=B4=20?= =?UTF-8?q?=EC=95=84=EB=8B=8C,=20id=EA=B0=92=EC=9D=84=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20workspace=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useSocketStore에서 id를 매개변수로 사용하게 변경 - auth 에 담아서 id값을 그대로 송신한다. #183 --- client/src/App.tsx | 10 +++++----- client/src/stores/useSocketStore.ts | 4 ++-- server/src/crdt/crdt.gateway.ts | 13 ++----------- server/src/crdt/crdt.service.ts | 22 +++++++++++++++++++++- 4 files changed, 30 insertions(+), 19 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 6ceda264..3ee69b69 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,9 +1,9 @@ -import { useEffect } from "react"; import { useRefreshQuery } from "@apis/auth"; -import { ErrorModal } from "@components/modal/ErrorModal"; -import { WorkSpace } from "@features/workSpace/WorkSpace"; import { useErrorStore } from "@stores/useErrorStore"; import { useUserInfo } from "@stores/useUserStore"; +import { useEffect } from "react"; +import { ErrorModal } from "@components/modal/ErrorModal"; +import { WorkSpace } from "@features/workSpace/WorkSpace"; import { useSocketStore } from "./stores/useSocketStore"; const App = () => { @@ -20,13 +20,13 @@ const App = () => { useEffect(() => { const socketStore = useSocketStore.getState(); - socketStore.init(accessToken); + socketStore.init(id); return () => { setTimeout(() => { socketStore.cleanup(); }, 0); }; - }, [accessToken]); + }, [id]); return ( <> diff --git a/client/src/stores/useSocketStore.ts b/client/src/stores/useSocketStore.ts index 0cb0b8ed..3aab7de0 100644 --- a/client/src/stores/useSocketStore.ts +++ b/client/src/stores/useSocketStore.ts @@ -51,7 +51,7 @@ export const useSocketStore = create((set, get) => ({ clientId: null, workspace: null, - init: (accessToken: string | null) => { + init: (id: string | null) => { const { socket: existingSocket } = get(); if (existingSocket) { @@ -68,7 +68,7 @@ export const useSocketStore = create((set, get) => ({ reconnectionAttempts: 5, reconnectionDelay: 1000, auth: { - token: accessToken, + userId: id, }, autoConnect: false, }); diff --git a/server/src/crdt/crdt.gateway.ts b/server/src/crdt/crdt.gateway.ts index 94fcf97b..d8252f07 100644 --- a/server/src/crdt/crdt.gateway.ts +++ b/server/src/crdt/crdt.gateway.ts @@ -60,22 +60,14 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa */ async handleConnection(client: Socket) { try { - // 현재 토큰 처리를 어떻게 하는지 몰라 일단 null로 가정하고 guest환경을 만들겠음. - const token = client.handshake.auth.token; let userId = null; - if (token) { - try { - const payload = this.jwtService.verify(token, { secret: process.env.JWT_SECRET }); - userId = payload.sub; - } catch (e) { - this.logger.warn("Invalid token, trating as guest"); - } - } + userId = client.handshake.auth.userId; if (!userId) { userId = "guest"; } client.data.userId = userId; client.join(userId); + // userId라는 방. const currentWorkSpace = await this.workSpaceService.getWorkspace(userId).serialize(); client.emit("workspace", currentWorkSpace); @@ -151,7 +143,6 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa }; client.emit("create/page", operation); client.to(userId).emit("create/page", operation); - //client.broadcast.emit("create/page", operation); } catch (error) { this.logger.error( `Page Create 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, diff --git a/server/src/crdt/crdt.service.ts b/server/src/crdt/crdt.service.ts index a0dc44e7..28701f0b 100644 --- a/server/src/crdt/crdt.service.ts +++ b/server/src/crdt/crdt.service.ts @@ -17,7 +17,6 @@ export class workSpaceService implements OnModuleInit { const guestWorkspace = new CRDTWorkSpace("guest", []); this.workspaces.set("guest", guestWorkspace); } - getWorkspace(userId: string): CRDTWorkSpace { if (!this.workspaces.has(userId)) { // 새로운 워크스페이스 생성 @@ -28,6 +27,27 @@ export class workSpaceService implements OnModuleInit { } } +// // 1. 연산마다 mongoDB값을 조작할 것인지, +// assync hand createPage MongoDB(operation){ +// 분석을해서 +// const 어쩌구 = await doc.findOne +// mongoDb 의 어떤 +// page[pageId] = 생성 ; +// } +// 어떤 pageId에 3번째 블럭에 2번째 인덱스 char를 삭제한다. +// operation을 분리해서 +// mongoDB를 그부분만 조작하도록 한다. + +// pageId[id] = +// workspaceId[id] = +// editor[id] + +// // 2. 연산마다 상태로 update를 할 것 인지. create/page -> +// 서버의 인스턴스 상태를 통째로 mongoDB에다가 +// 덮어씌워버림. -> 인스턴스 상태가 얼마나 많은데.. +// 직렬화도 문제임. +// 스키마도 복잡할 것으로 예상됨. + // async onModuleInit() { // try { // // MongoDB에서 Workspace 문서 가져오기 From dbd235da4efc2f07d987bf5c3c0ac5d4140da510 Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Mon, 25 Nov 2024 18:39:00 +0900 Subject: [PATCH 08/18] =?UTF-8?q?feat:=20pageId=20=EB=B3=84=EB=A1=9C=20crd?= =?UTF-8?q?t=20=EC=97=B0=EC=82=B0=20=EC=88=98=ED=96=89=ED=95=98=EA=B2=8C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pageId별로 socket room을 만들어서 관리함. - 클라이언트에서 지정된 pageId 가 아니면 연산을 반영하지 않음. #183 --- client/src/features/editor/Editor.tsx | 14 ++--- client/src/features/page/Page.tsx | 22 +++++-- server/src/crdt/crdt.gateway.ts | 91 +++++++++++++++++++++++++-- 3 files changed, 109 insertions(+), 18 deletions(-) diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index 42956749..e4e02d87 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -168,7 +168,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr const unsubscribe = subscribeToRemoteOperations({ onRemoteBlockInsert: (operation) => { console.log(operation, "block : 입력 확인합니다이"); - if (!editorCRDT.current) return; + if (!editorCRDT.current || operation.pageId !== pageId) return; editorCRDT.current.remoteInsert(operation); setEditorState((prev) => ({ clock: editorCRDT.current.clock, @@ -179,7 +179,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onRemoteBlockDelete: (operation) => { console.log(operation, "block : 삭제 확인합니다이"); - if (!editorCRDT.current) return; + if (!editorCRDT.current || operation.pageId !== pageId) return; editorCRDT.current.remoteDelete(operation); setEditorState((prev) => ({ clock: editorCRDT.current.clock, @@ -190,7 +190,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onRemoteCharInsert: (operation) => { console.log(operation, "char : 입력 확인합니다이"); - if (!editorCRDT.current) return; + if (!editorCRDT.current || operation.pageId !== pageId) return; const targetBlock = editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; targetBlock.crdt.remoteInsert(operation); @@ -203,7 +203,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onRemoteCharDelete: (operation) => { console.log(operation, "char : 삭제 확인합니다이"); - if (!editorCRDT.current) return; + if (!editorCRDT.current || operation.pageId !== pageId) return; const targetBlock = editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; targetBlock.crdt.remoteDelete(operation); @@ -216,9 +216,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onRemoteBlockUpdate: (operation) => { console.log(operation, "block : 업데이트 확인합니다이"); - if (!editorCRDT.current) return; - // ?? - console.log("타입", operation.node); + if (!editorCRDT.current || operation.pageId !== pageId) return; editorCRDT.current.remoteUpdate(operation.node, operation.pageId); setEditorState((prev) => ({ clock: editorCRDT.current.clock, @@ -229,7 +227,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onRemoteBlockReorder: (operation) => { console.log(operation, "block : 재정렬 확인합니다이"); - if (!editorCRDT.current) return; + if (!editorCRDT.current || operation.pageId !== pageId) return; editorCRDT.current.remoteReorder(operation); setEditorState((prev) => ({ clock: editorCRDT.current.clock, diff --git a/client/src/features/page/Page.tsx b/client/src/features/page/Page.tsx index 23bf3d15..be4aeffc 100644 --- a/client/src/features/page/Page.tsx +++ b/client/src/features/page/Page.tsx @@ -1,10 +1,11 @@ import { serializedEditorDataProps } from "@noctaCrdt/Interfaces"; import { motion, AnimatePresence } from "framer-motion"; +import { useEffect } from "react"; import { Editor } from "@features/editor/Editor"; +import { useSocketStore } from "@src/stores/useSocketStore"; import { Page as PageType } from "@src/types/page"; import { pageAnimation, resizeHandleAnimation } from "./Page.animation"; import { pageContainer, pageHeader, resizeHandle } from "./Page.style"; - import { PageControlButton } from "./components/PageControlButton/PageControlButton"; import { PageTitle } from "./components/PageTitle/PageTitle"; import { usePage } from "./hooks/usePage"; @@ -30,9 +31,6 @@ export const Page = ({ }: PageProps) => { const { position, size, pageDrag, pageResize, pageMinimize, pageMaximize } = usePage({ x, y }); - // TODO: workspace에서 pageId, editorCRDT props로 받아와야 함 - // const {} = useSocket(); - const onTitleChange = (newTitle: string) => { handleTitleChange(id, newTitle); }; @@ -43,6 +41,22 @@ export const Page = ({ } }; + useEffect(() => { + const socketStore = useSocketStore.getState(); + if (!socketStore.socket) return; + + // 페이지 열기 시 join/page 이벤트 전송 + socketStore.socket.emit("join/page", { pageId: id }); + console.log(id, "전송완료"); + // 페이지 닫기 시 leave/page 이벤트 전송 + return () => { + if (socketStore.socket) { + socketStore.socket.emit("leave/page", { pageId: id }); + console.log(id, "퇴장완료"); + } + }; + }, [id]); + return ( { + const clientInfo = this.clientMap.get(client.id); + if (!clientInfo) { + throw new WsException("Client information not found"); + } + + try { + const pageId = data.pageId; + const userId = client.data.userId; + + // 워크스페이스에서 해당 페이지 찾기 + const workspace = this.workSpaceService.getWorkspace(userId); + const page = workspace.pageList.find((p) => p.id === pageId); + + // pageId에 가입 시키기 + client.join(pageId); + if (!page) { + throw new WsException(`Page with id ${pageId} not found`); + } + + // 페이지 데이터를 요청한 클라이언트에게 전송 + client.emit("join/page", { + pageId, + serializedPage: page.serialize(), + }); + + this.logger.log(`Client ${clientInfo.clientId} joined page ${pageId}`); + } catch (error) { + this.logger.error( + `페이지 참여 중 오류 발생 - Client ID: ${clientInfo.clientId}`, + error.stack, + ); + throw new WsException(`페이지 참여 실패: ${error.message}`); + } + } + + /** + * 페이지 퇴장 처리 + * 클라이언트가 특정 페이지에서 나갈 때 호출됨 + */ + @SubscribeMessage("leave/page") + async handleLeavePage( + @MessageBody() data: { pageId: string }, + @ConnectedSocket() client: Socket, + ): Promise { + const clientInfo = this.clientMap.get(client.id); + if (!clientInfo) { + throw new WsException("Client information not found"); + } + + try { + const pageId = data.pageId; + const userId = client.data.userId; + client.leave(pageId); + + this.logger.log(`Client ${clientInfo.clientId} leaved page ${pageId}`); + } catch (error) { + this.logger.error( + `페이지 퇴장 중 오류 발생 - Client ID: ${clientInfo.clientId}`, + error.stack, + ); + throw new WsException(`페이지 퇴장 실패: ${error.message}`); + } + } + /** * 블록 삽입 연산 처리 */ @@ -179,7 +252,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa node: data.node, pageId: data.pageId, } as RemoteBlockUpdateOperation; - client.to(userId).emit("update/block", operation); + client.to(client.data.pageId).emit("update/block", operation); } catch (error) { this.logger.error( `블록 Update 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -217,7 +290,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa node: data.node, pageId: data.pageId, }; - client.to(userId).emit("insert/block", operation); + client.to(data.pageId).emit("insert/block", operation); } catch (error) { this.logger.error( `Block Insert 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -237,6 +310,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ): Promise { const clientInfo = this.clientMap.get(client.id); try { + console.log("인서트 char", client.data.pageId); this.logger.debug( `Insert 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), @@ -259,8 +333,9 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa const operation = { node: data.node, blockId: data.blockId, + pageId: data.pageId, }; - client.to(userId).emit("insert/char", operation); + client.to(data.pageId).emit("insert/char", operation); } catch (error) { this.logger.error( `Char Insert 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -269,6 +344,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa throw new WsException(`Insert 연산 실패: ${error.message}`); } } + /** * 삭제 연산 처리 */ @@ -279,6 +355,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ): Promise { const clientInfo = this.clientMap.get(client.id); try { + console.log("딜리트 블록", client.data.pageId); this.logger.debug( `Delete 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), @@ -296,7 +373,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa clock: data.clock, pageId: data.pageId, }; - client.to(userId).emit("delete/block", operation); + client.to(data.pageId).emit("delete/block", operation); } catch (error) { this.logger.error( `Block Delete 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -316,6 +393,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ): Promise { const clientInfo = this.clientMap.get(client.id); try { + console.log("딜리트 캐릭터", client.data.pageId); this.logger.debug( `Delete 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), @@ -337,8 +415,9 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa targetId: data.targetId, clock: data.clock, blockId: data.blockId, + pageId: data.pageId, }; - client.to(userId).emit("delete/char", operation); + client.to(data.pageId).emit("delete/char", operation); } catch (error) { this.logger.error( `Char Delete 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -375,7 +454,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa afterId: data.afterId, pageId: data.pageId, } as RemoteBlockReorderOperation; - client.to(userId).emit("reorder/block", operation); + client.to(data.pageId).emit("reorder/block", operation); } catch (error) { this.logger.error( `블록 Reorder 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, From 45d28b37960af596080ca964c64c62d8144b2212 Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Mon, 25 Nov 2024 18:39:16 +0900 Subject: [PATCH 09/18] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=BD=98=EC=86=94=EB=A1=9C=EA=B7=B8=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/editor/Editor.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index e4e02d87..712fd614 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -249,11 +249,8 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr const tempBlock = () => { const index = editorCRDT.current.LinkedList.spread().length; - - // 로컬 삽입을 수행하고 연산 객체를 반환받음 const operation = editorCRDT.current.localInsert(index, ""); sendBlockInsertOperation({ node: operation.node, pageId }); - console.log("operation clock", operation.node); setEditorState(() => ({ clock: operation.node.id.clock, linkedList: editorCRDT.current.LinkedList, From 8ea5866de01c54e06995abb2283f177eebd0e14e Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Mon, 25 Nov 2024 20:44:22 +0900 Subject: [PATCH 10/18] =?UTF-8?q?feat:=20page=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=88=84=EB=A5=BC=EB=95=8C=20=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20socket=EC=9D=84=20=EC=97=B0=EA=B2=B0=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=84=9C=EB=B2=84=EB=A1=9C=EB=B6=80=ED=84=B0=20?= =?UTF-8?q?=EC=B5=9C=EC=8B=A0=20page=20=EC=9D=B8=EC=8A=A4=ED=84=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=A0=95=EB=B3=B4=20=EB=B0=9B=EC=95=84=EC=98=A4?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 현재 loading 때문에 깜빡 거리는 문제 있음 - crdt 변경된 데이터를 Cache화 해서 전부 갱신하기보다 바뀐부분만 pages를 변경하여 page props로 전달 #183 --- client/src/features/editor/Editor.tsx | 51 +++++++++++++------ .../editor/components/block/Block.tsx | 1 - client/src/features/page/Page.tsx | 36 ++++++++++--- client/src/features/page/hooks/usePage.ts | 2 +- client/src/features/workSpace/WorkSpace.tsx | 13 ++++- .../workSpace/hooks/usePagesManage.ts | 16 +++++- 6 files changed, 91 insertions(+), 28 deletions(-) diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index 712fd614..105b5ae2 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -8,7 +8,7 @@ import { RemoteCharInsertOperation, serializedEditorDataProps, } from "node_modules/@noctaCrdt/Interfaces.ts"; -import { useRef, useState, useCallback, useEffect, useMemo } from "react"; +import { useRef, useState, useCallback, useEffect } from "react"; import { useSocketStore } from "@src/stores/useSocketStore.ts"; import { editorContainer, editorTitleContainer, editorTitle } from "./Editor.style"; import { Block } from "./components/block/Block.tsx"; @@ -19,7 +19,7 @@ import { useMarkdownGrammer } from "./hooks/useMarkdownGrammer"; interface EditorProps { onTitleChange: (title: string) => void; pageId: string; - serializedEditorData: serializedEditorDataProps; + serializedEditorData: serializedEditorDataProps | null; } export interface EditorStateProps { @@ -37,18 +37,35 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr sendBlockDeleteOperation, sendBlockUpdateOperation, } = useSocketStore(); - const editorCRDTInstance = useMemo(() => { - const editor = new EditorCRDT(serializedEditorData.client); - editor.deserialize(serializedEditorData); - return editor; - }, [serializedEditorData]); + const { clientId } = useSocketStore(); - const editorCRDT = useRef(editorCRDTInstance); + const editorCRDT = useRef( + (() => { + if (!serializedEditorData) { + return new EditorCRDT(clientId ? clientId : 0); + } + const editor = new EditorCRDT(serializedEditorData.client); + editor.deserialize(serializedEditorData); + return editor; + })(), + ); + + // Editor의 상태도 editorCRDT 기반으로 초기화 const [editorState, setEditorState] = useState({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, - currentBlock: null as BlockId | null, + currentBlock: null, }); + + // editorCRDT가 변경될 때마다 editorState 업데이트 + useEffect(() => { + setEditorState({ + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, + currentBlock: null, + }); + }, [editorCRDT]); + const { sensors, handleDragEnd } = useBlockDragAndDrop({ editorCRDT: editorCRDT.current, editorState, @@ -168,7 +185,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr const unsubscribe = subscribeToRemoteOperations({ onRemoteBlockInsert: (operation) => { console.log(operation, "block : 입력 확인합니다이"); - if (!editorCRDT.current || operation.pageId !== pageId) return; + if (!editorCRDT || operation.pageId !== pageId) return; editorCRDT.current.remoteInsert(operation); setEditorState((prev) => ({ clock: editorCRDT.current.clock, @@ -179,7 +196,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onRemoteBlockDelete: (operation) => { console.log(operation, "block : 삭제 확인합니다이"); - if (!editorCRDT.current || operation.pageId !== pageId) return; + if (!editorCRDT || operation.pageId !== pageId) return; editorCRDT.current.remoteDelete(operation); setEditorState((prev) => ({ clock: editorCRDT.current.clock, @@ -190,7 +207,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onRemoteCharInsert: (operation) => { console.log(operation, "char : 입력 확인합니다이"); - if (!editorCRDT.current || operation.pageId !== pageId) return; + if (!editorCRDT || operation.pageId !== pageId) return; const targetBlock = editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; targetBlock.crdt.remoteInsert(operation); @@ -203,7 +220,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onRemoteCharDelete: (operation) => { console.log(operation, "char : 삭제 확인합니다이"); - if (!editorCRDT.current || operation.pageId !== pageId) return; + if (!editorCRDT || operation.pageId !== pageId) return; const targetBlock = editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; targetBlock.crdt.remoteDelete(operation); @@ -216,7 +233,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onRemoteBlockUpdate: (operation) => { console.log(operation, "block : 업데이트 확인합니다이"); - if (!editorCRDT.current || operation.pageId !== pageId) return; + if (!editorCRDT || operation.pageId !== pageId) return; editorCRDT.current.remoteUpdate(operation.node, operation.pageId); setEditorState((prev) => ({ clock: editorCRDT.current.clock, @@ -227,7 +244,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onRemoteBlockReorder: (operation) => { console.log(operation, "block : 재정렬 확인합니다이"); - if (!editorCRDT.current || operation.pageId !== pageId) return; + if (!editorCRDT || operation.pageId !== pageId) return; editorCRDT.current.remoteReorder(operation); setEditorState((prev) => ({ clock: editorCRDT.current.clock, @@ -258,6 +275,10 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr })); }; + // 로딩 상태 체크 + if (!serializedEditorData) { + return
Loading editor data...
; + } return (
diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index 3baa4827..b1a77a0d 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -37,7 +37,6 @@ export const Block: React.FC = memo( onCopySelect, onDeleteSelect, }: BlockProps) => { - console.log("블록 초기화 상태", block); const blockRef = useRef(null); const blockCRDTRef = useRef(block); diff --git a/client/src/features/page/Page.tsx b/client/src/features/page/Page.tsx index be4aeffc..d26ee999 100644 --- a/client/src/features/page/Page.tsx +++ b/client/src/features/page/Page.tsx @@ -1,6 +1,6 @@ import { serializedEditorDataProps } from "@noctaCrdt/Interfaces"; import { motion, AnimatePresence } from "framer-motion"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { Editor } from "@features/editor/Editor"; import { useSocketStore } from "@src/stores/useSocketStore"; import { Page as PageType } from "@src/types/page"; @@ -14,6 +14,7 @@ interface PageProps extends PageType { handlePageSelect: ({ pageId, isSidebar }: { pageId: string; isSidebar?: boolean }) => void; handlePageClose: (pageId: string) => void; handleTitleChange: (pageId: string, newTitle: string) => void; + updatePageData: (pageId: string, newData: serializedEditorDataProps) => void; serializedEditorData: serializedEditorDataProps; } @@ -27,9 +28,13 @@ export const Page = ({ handlePageSelect, handlePageClose, handleTitleChange, + updatePageData, serializedEditorData, }: PageProps) => { const { position, size, pageDrag, pageResize, pageMinimize, pageMaximize } = usePage({ x, y }); + const [isLoading, setIsLoading] = useState(true); + const [serializedEditorDatas, setSerializedEditorDatas] = + useState(serializedEditorData); const onTitleChange = (newTitle: string) => { handleTitleChange(id, newTitle); @@ -41,22 +46,37 @@ export const Page = ({ } }; + // serializedEditorData prop이 변경되면 local state도 업데이트 + useEffect(() => { + setSerializedEditorDatas(serializedEditorData); + }, [serializedEditorData, updatePageData]); + useEffect(() => { const socketStore = useSocketStore.getState(); if (!socketStore.socket) return; - - // 페이지 열기 시 join/page 이벤트 전송 + // 페이지 데이터 수신 핸들러 + const handlePageData = (data: { pageId: string; serializedPage: any }) => { + if (data.pageId === id) { + console.log("Received new editor data:", data); + setSerializedEditorDatas(data.serializedPage.crdt); + updatePageData(id, data.serializedPage.crdt); + setIsLoading(false); + } + }; + socketStore.socket.on("join/page", handlePageData); socketStore.socket.emit("join/page", { pageId: id }); - console.log(id, "전송완료"); - // 페이지 닫기 시 leave/page 이벤트 전송 + return () => { if (socketStore.socket) { socketStore.socket.emit("leave/page", { pageId: id }); - console.log(id, "퇴장완료"); + socketStore.socket.off("join/page", handlePageData); } }; - }, [id]); + }, [id, updatePageData]); + if (isLoading || !serializedEditorDatas) { + return
Loading page content...
; + } return ( { const { isLoading, isInitialized, error } = useWorkspaceInit(); const { workspace: workspaceMetadata, clientId } = useSocketStore(); - const { pages, fetchPage, selectPage, closePage, updatePageTitle, initPages, initPagePosition } = - usePagesManage(workspace, clientId); + const { + pages, + fetchPage, + selectPage, + closePage, + updatePageTitle, + initPages, + initPagePosition, + updatePageData, + } = usePagesManage(workspace, clientId); const visiblePages = pages.filter((page) => page.isVisible); useEffect(() => { @@ -59,6 +67,7 @@ export const WorkSpace = () => { handlePageSelect={selectPage} handlePageClose={closePage} handleTitleChange={updatePageTitle} + updatePageData={updatePageData} /> ))}
diff --git a/client/src/features/workSpace/hooks/usePagesManage.ts b/client/src/features/workSpace/hooks/usePagesManage.ts index 20502d37..b6ea6f02 100644 --- a/client/src/features/workSpace/hooks/usePagesManage.ts +++ b/client/src/features/workSpace/hooks/usePagesManage.ts @@ -1,6 +1,7 @@ +import { serializedEditorDataProps } from "@noctaCrdt/Interfaces"; import { Page as CRDTPage } from "@noctaCrdt/Page"; import { WorkSpace } from "@noctaCrdt/WorkSpace"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState, useRef, useCallback } from "react"; import { useSocketStore } from "@src/stores/useSocketStore"; import { Page } from "@src/types/page"; @@ -10,6 +11,7 @@ const PAGE_OFFSET = 60; export const usePagesManage = (workspace: WorkSpace | null, clientId: number | null) => { const [pages, setPages] = useState([]); const { subscribeToPageOperations, sendPageCreateOperation } = useSocketStore(); + const pageDataCache = useRef>(new Map()); const subscriptionRef = useRef(false); useEffect(() => { if (!workspace) return; @@ -34,6 +36,15 @@ export const usePagesManage = (workspace: WorkSpace | null, clientId: number | n }; }, [workspace, pages]); + const updatePageData = useCallback((pageId: string, newData: serializedEditorDataProps) => { + pageDataCache.current.set(pageId, newData); + setPages((prevPages) => + prevPages.map((page) => + page.id === pageId ? { ...page, serializedEditorData: newData } : page, + ), + ); + }, []); + const getZIndex = () => { return Math.max(0, ...pages.map((page) => page.zIndex)) + 1; }; @@ -66,6 +77,7 @@ export const usePagesManage = (workspace: WorkSpace | null, clientId: number | n }; const selectPage = ({ pageId }: { pageId: string }) => { + const cachedData = pageDataCache.current.get(pageId); setPages((prevPages) => prevPages.map((page) => ({ ...page, @@ -73,6 +85,7 @@ export const usePagesManage = (workspace: WorkSpace | null, clientId: number | n ...(page.id === pageId && { zIndex: getZIndex(), isVisible: true, + ...(cachedData && { serializedEditorData: cachedData }), }), })), ); @@ -129,6 +142,7 @@ export const usePagesManage = (workspace: WorkSpace | null, clientId: number | n fetchPage, selectPage, closePage, + updatePageData, updatePageTitle, initPages, initPagePosition, From f76e17f252dd3ea0db1f087198b68767a1e3a9c2 Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Mon, 25 Nov 2024 20:46:49 +0900 Subject: [PATCH 11/18] =?UTF-8?q?chore:=20=EB=A6=B0=ED=8A=B8=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 객체 구조 할당으로 수신 --- server/src/crdt/crdt.gateway.ts | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/server/src/crdt/crdt.gateway.ts b/server/src/crdt/crdt.gateway.ts index 8a2a2a2d..9dc7d917 100644 --- a/server/src/crdt/crdt.gateway.ts +++ b/server/src/crdt/crdt.gateway.ts @@ -60,8 +60,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa */ async handleConnection(client: Socket) { try { - let userId = null; - userId = client.handshake.auth.userId; + let { userId } = client.handshake.auth; if (!userId) { userId = "guest"; } @@ -130,8 +129,8 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } try { - const pageId = data.pageId; - const userId = client.data.userId; + const { pageId } = data; + const { userId } = client.data; // 워크스페이스에서 해당 페이지 찾기 const workspace = this.workSpaceService.getWorkspace(userId); @@ -174,8 +173,8 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } try { - const pageId = data.pageId; - const userId = client.data.userId; + const { pageId } = data; + const { userId } = client.data; client.leave(pageId); this.logger.log(`Client ${clientInfo.clientId} leaved page ${pageId}`); @@ -202,7 +201,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa `Page create 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), ); - const userId = client.data.userId; + const { userId } = client.data; const workspace = this.workSpaceService.getWorkspace(userId); const newEditorCRDT = new EditorCRDT(data.clientId); @@ -239,7 +238,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa JSON.stringify(data), ); - const userId = client.data.userId; + const { userId } = client.data; const workspace = this.workSpaceService.getWorkspace(userId); const currentPage = workspace.pageList.find((p) => p.id === data.pageId); @@ -277,7 +276,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa JSON.stringify(data), ); - const userId = client.data.userId; + const { userId } = client.data; const currentPage = this.workSpaceService .getWorkspace(userId) .pageList.find((p) => p.id === data.pageId); @@ -316,7 +315,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa JSON.stringify(data), ); - const userId = client.data.userId; + const { userId } = client.data; const currentPage = this.workSpaceService .getWorkspace(userId) .pageList.find((p) => p.id === data.pageId); @@ -360,7 +359,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa `Delete 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), ); - const userId = client.data.userId; + const { userId } = client.data; const currentPage = this.workSpaceService .getWorkspace(userId) .pageList.find((p) => p.id === data.pageId); @@ -398,7 +397,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa `Delete 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), ); - const userId = client.data.userId; + const { userId } = client.data; const currentPage = this.workSpaceService .getWorkspace(userId) .pageList.find((p) => p.id === data.pageId); @@ -438,7 +437,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa `블록 Reorder 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), ); - const userId = client.data.userId; + const { userId } = client.data; const workspace = this.workSpaceService.getWorkspace(userId); const currentPage = workspace.pageList.find((p) => p.id === data.pageId); @@ -480,7 +479,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa clientId: clientInfo?.clientId, position: data.position, }; - const userId = client.data.userId; + const { userId } = client.data; client.to(userId).emit("cursor", operation); } catch (error) { this.logger.error( From 44ee6ac33c37f6c92a1c62dcbbe127ad1992168f Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Mon, 25 Nov 2024 22:39:59 +0900 Subject: [PATCH 12/18] =?UTF-8?q?chore:=20lint=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 - ) 누락 --- client/src/features/editor/Editor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index 0403416b..fc6edaf9 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -301,7 +301,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr setEditorState(() => ({ clock: operation.node.id.clock, linkedList: editorCRDT.current.LinkedList, - }); + })); }; // 로딩 상태 체크 From 03d7d40f577f114eaaf5df9a23d5213c92c6de6e Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Mon, 25 Nov 2024 22:42:53 +0900 Subject: [PATCH 13/18] =?UTF-8?q?chore:=20=EB=B9=8C=EB=93=9C=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dev와 상황 동기화 하며 누락된 id 재선언 #183 --- client/src/App.tsx | 7 +++---- client/src/features/editor/Editor.tsx | 1 - client/src/features/page/Page.tsx | 1 - 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 1a4ece9f..5462bfa0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,15 +1,14 @@ -import { useRefreshQuery } from "@apis/auth"; -import { useErrorStore } from "@stores/useErrorStore"; -import { useUserInfo } from "@stores/useUserStore"; import { useEffect } from "react"; import { ErrorModal } from "@components/modal/ErrorModal"; import { WorkSpace } from "@features/workSpace/WorkSpace"; +import { useErrorStore } from "@stores/useErrorStore"; +import { useUserInfo } from "@stores/useUserStore"; import { useSocketStore } from "./stores/useSocketStore"; const App = () => { // TODO 라우터, react query 설정 const { isErrorModalOpen, errorMessage } = useErrorStore(); - + const { id } = useUserInfo(); useEffect(() => { const socketStore = useSocketStore.getState(); socketStore.init(id); diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index fc6edaf9..483d857c 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -66,7 +66,6 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr setEditorState({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, - currentBlock: null, }); }, [editorCRDT]); diff --git a/client/src/features/page/Page.tsx b/client/src/features/page/Page.tsx index 480bd7df..3f43bb33 100644 --- a/client/src/features/page/Page.tsx +++ b/client/src/features/page/Page.tsx @@ -4,7 +4,6 @@ import { useEffect, useState } from "react"; import { Editor } from "@features/editor/Editor"; import { useSocketStore } from "@src/stores/useSocketStore"; import { Page as PageType } from "@src/types/page"; -import { pageAnimation, resizeHandleAnimation } from "./Page.animation"; import { pageContainer, pageHeader, resizeHandles } from "./Page.style"; import { PageControlButton } from "./components/PageControlButton/PageControlButton"; import { PageTitle } from "./components/PageTitle/PageTitle"; From 71cbb2cdb5835e4dfdf7ab9122e207d44f1113ed Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Tue, 26 Nov 2024 01:01:41 +0900 Subject: [PATCH 14/18] =?UTF-8?q?feat:=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20=ED=82=AC=EB=95=8C=20socket=EC=9D=84=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=ED=95=98=EA=B3=A0,=20pages=20=EB=A5=BC=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=ED=95=A0=EB=95=8C=20=ED=86=B5=EC=8B=A0=ED=95=98?= =?UTF-8?q?=EC=97=AC=20page=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 캐럿이 이상하게 튀어서 역직렬화가 안되는 문제 발생 #183 --- client/src/components/sidebar/Sidebar.tsx | 6 +- client/src/features/editor/Editor.tsx | 4 +- client/src/features/page/Page.tsx | 31 +------ client/src/features/workSpace/WorkSpace.tsx | 25 +++--- .../workSpace/hooks/usePagesManage.ts | 81 +++++++++++++++---- client/src/types/page.ts | 3 +- server/src/crdt/crdt.gateway.ts | 24 +++--- 7 files changed, 106 insertions(+), 68 deletions(-) diff --git a/client/src/components/sidebar/Sidebar.tsx b/client/src/components/sidebar/Sidebar.tsx index 4659e0f6..02645afb 100644 --- a/client/src/components/sidebar/Sidebar.tsx +++ b/client/src/components/sidebar/Sidebar.tsx @@ -14,11 +14,11 @@ import { sidebarContainer, navWrapper, plusIconBox, sidebarToggleButton } from " export const Sidebar = ({ pages, handlePageAdd, - handlePageSelect, + handlePageOpen, }: { pages: Page[]; handlePageAdd: () => void; - handlePageSelect: ({ pageId }: { pageId: string }) => void; + handlePageOpen: ({ pageId }: { pageId: string }) => void; }) => { const visiblePages = pages.filter((page) => page.isVisible); const isMaxVisiblePage = visiblePages.length >= MAX_VISIBLE_PAGE; @@ -31,7 +31,7 @@ export const Sidebar = ({ openModal(); return; } - handlePageSelect({ pageId: id }); + handlePageOpen({ pageId: id }); }; const handleAddPageButtonClick = () => { diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index 483d857c..d2bc379e 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -205,13 +205,13 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr const subscriptionRef = useRef(false); useLayoutEffect(() => { - if (!editorCRDT.current.currentBlock) return; + if (!editorCRDT || !editorCRDT.current.currentBlock) return; setCaretPosition({ blockId: editorCRDT.current.currentBlock.id, linkedList: editorCRDT.current.LinkedList, position: editorCRDT.current.currentBlock?.crdt.currentCaret, }); - }, [editorCRDT.current.currentBlock?.crdt.read().length]); + }, [editorCRDT, editorCRDT?.current.currentBlock?.crdt.read().length]); useEffect(() => { if (subscriptionRef.current) return; diff --git a/client/src/features/page/Page.tsx b/client/src/features/page/Page.tsx index 3f43bb33..71eba5e7 100644 --- a/client/src/features/page/Page.tsx +++ b/client/src/features/page/Page.tsx @@ -2,7 +2,6 @@ import { serializedEditorDataProps } from "@noctaCrdt/Interfaces"; import { motion, AnimatePresence } from "framer-motion"; import { useEffect, useState } from "react"; import { Editor } from "@features/editor/Editor"; -import { useSocketStore } from "@src/stores/useSocketStore"; import { Page as PageType } from "@src/types/page"; import { pageContainer, pageHeader, resizeHandles } from "./Page.style"; import { PageControlButton } from "./components/PageControlButton/PageControlButton"; @@ -14,7 +13,7 @@ interface PageProps extends PageType { handlePageClose: (pageId: string) => void; handleTitleChange: (pageId: string, newTitle: string) => void; updatePageData: (pageId: string, newData: serializedEditorDataProps) => void; - serializedEditorData: serializedEditorDataProps; + serializedEditorData: serializedEditorDataProps | null; } export const Page = ({ @@ -31,7 +30,6 @@ export const Page = ({ serializedEditorData, }: PageProps) => { const { position, size, pageDrag, pageResize, pageMinimize, pageMaximize } = usePage({ x, y }); - const [isLoading, setIsLoading] = useState(true); const [serializedEditorDatas, setSerializedEditorDatas] = useState(serializedEditorData); @@ -50,31 +48,8 @@ export const Page = ({ setSerializedEditorDatas(serializedEditorData); }, [serializedEditorData, updatePageData]); - useEffect(() => { - const socketStore = useSocketStore.getState(); - if (!socketStore.socket) return; - // 페이지 데이터 수신 핸들러 - const handlePageData = (data: { pageId: string; serializedPage: any }) => { - if (data.pageId === id) { - console.log("Received new editor data:", data); - setSerializedEditorDatas(data.serializedPage.crdt); - updatePageData(id, data.serializedPage.crdt); - setIsLoading(false); - } - }; - socketStore.socket.on("join/page", handlePageData); - socketStore.socket.emit("join/page", { pageId: id }); - - return () => { - if (socketStore.socket) { - socketStore.socket.emit("leave/page", { pageId: id }); - socketStore.socket.off("join/page", handlePageData); - } - }; - }, [id, updatePageData]); - - if (isLoading || !serializedEditorDatas) { - return
Loading page content...
; + if (!serializedEditorDatas) { + return null; } return ( diff --git a/client/src/features/workSpace/WorkSpace.tsx b/client/src/features/workSpace/WorkSpace.tsx index b237e4a9..97c32792 100644 --- a/client/src/features/workSpace/WorkSpace.tsx +++ b/client/src/features/workSpace/WorkSpace.tsx @@ -24,6 +24,7 @@ export const WorkSpace = () => { initPages, initPagePosition, updatePageData, + openPage, } = usePagesManage(workspace, clientId); const visiblePages = pages.filter((page) => page.isVisible); @@ -58,18 +59,20 @@ export const WorkSpace = () => { opacity: isInitialized && !isLoading ? 1 : 0, })} > - +
- {visiblePages.map((page) => ( - - ))} + {visiblePages.map((page) => + page.isLoaded ? ( + + ) : null, + )}
diff --git a/client/src/features/workSpace/hooks/usePagesManage.ts b/client/src/features/workSpace/hooks/usePagesManage.ts index b6ea6f02..1bfb050a 100644 --- a/client/src/features/workSpace/hooks/usePagesManage.ts +++ b/client/src/features/workSpace/hooks/usePagesManage.ts @@ -11,7 +11,6 @@ const PAGE_OFFSET = 60; export const usePagesManage = (workspace: WorkSpace | null, clientId: number | null) => { const [pages, setPages] = useState([]); const { subscribeToPageOperations, sendPageCreateOperation } = useSocketStore(); - const pageDataCache = useRef>(new Map()); const subscriptionRef = useRef(false); useEffect(() => { if (!workspace) return; @@ -37,7 +36,6 @@ export const usePagesManage = (workspace: WorkSpace | null, clientId: number | n }, [workspace, pages]); const updatePageData = useCallback((pageId: string, newData: serializedEditorDataProps) => { - pageDataCache.current.set(pageId, newData); setPages((prevPages) => prevPages.map((page) => page.id === pageId ? { ...page, serializedEditorData: newData } : page, @@ -71,29 +69,82 @@ export const usePagesManage = (workspace: WorkSpace | null, clientId: number | n zIndex: getZIndex(), isActive: true, isVisible: true, + isLoaded: false, serializedEditorData, } as Page, ]); }; + // 이미 열린 페이지를 선택할 때 사용하는 함수 (데이터 가져오기 수행 안 함) const selectPage = ({ pageId }: { pageId: string }) => { - const cachedData = pageDataCache.current.get(pageId); setPages((prevPages) => - prevPages.map((page) => ({ - ...page, - isActive: page.id === pageId, - ...(page.id === pageId && { - zIndex: getZIndex(), - isVisible: true, - ...(cachedData && { serializedEditorData: cachedData }), - }), - })), + prevPages.map((page) => + page.id === pageId + ? { ...page, isActive: true, zIndex: getZIndex(), isVisible: true } + : { ...page, isActive: false }, + ), + ); + }; + // 페이지 데이터 로딩 상태 업데이트 함수 + const setPageDataReady = (pageId: string, isLoaded: boolean) => { + setPages((prevPages) => + prevPages.map((page) => (page.id === pageId ? { ...page, isLoaded } : page)), ); }; + // 페이지 데이터를 가져오는 함수 + const fetchPageData = (pageId: string) => { + const socketStore = useSocketStore.getState(); + const page = pages.find((p) => p.id === pageId); + + if (page && page.isLoaded) { + // 이미 데이터가 로드된 경우 아무 작업도 하지 않음 + return; + } + if (!socketStore.socket) return; + + // 페이지 데이터 수신 핸들러 + const handlePageData = (data: { pageId: string; serializedPage: any }) => { + if (data.pageId === pageId) { + console.log("Received new editor data:", data); + + // 페이지 데이터 업데이트 + updatePageData(pageId, data.serializedPage.crdt); + + // 로딩 상태 업데이트 + setPageDataReady(pageId, true); + + // 소켓 이벤트 해제 + socketStore.socket?.off("join/page", handlePageData); + } + }; + + // 소켓 이벤트 등록 및 데이터 요청 + socketStore.socket.on("join/page", handlePageData); + socketStore.socket.emit("join/page", { pageId }); + }; + // 페이지를 열 때 사용하는 함수 (데이터가 로드되지 않은 경우 데이터 가져오기 수행) + const openPage = ({ pageId }: { pageId: string }) => { + const page = pages.find((p) => p.id === pageId); + + if (page) { + fetchPageData(pageId); + + // 페이지를 활성화하고 표시 + setPages((prevPages) => + prevPages.map((p) => + p.id === pageId + ? { ...p, isActive: true, isVisible: true, zIndex: getZIndex() } + : { ...p, isActive: false }, + ), + ); + } + }; const closePage = (pageId: string) => { setPages((prevPages) => - prevPages.map((page) => (page.id === pageId ? { ...page, isVisible: false } : page)), + prevPages.map((page) => + page.id === pageId ? { ...page, isVisible: false, isLoaded: false } : page, + ), ); }; @@ -126,7 +177,8 @@ export const usePagesManage = (workspace: WorkSpace | null, clientId: number | n zIndex: index, isActive: index === 0, // 첫 번째 페이지를 활성화 isVisible: false, - serializedEditorData: crdtPage.crdt.serialize(), + isLoaded: false, + serializedEditorData: null, }) as Page, ); setPages(pageList); @@ -141,6 +193,7 @@ export const usePagesManage = (workspace: WorkSpace | null, clientId: number | n pages, fetchPage, selectPage, + openPage, closePage, updatePageData, updatePageTitle, diff --git a/client/src/types/page.ts b/client/src/types/page.ts index deb72a6b..60b0e702 100644 --- a/client/src/types/page.ts +++ b/client/src/types/page.ts @@ -9,7 +9,8 @@ export interface Page { zIndex: number; isActive: boolean; isVisible: boolean; - serializedEditorData: serializedEditorDataProps; + isLoaded: boolean; + serializedEditorData: serializedEditorDataProps | null; } export interface Position { diff --git a/server/src/crdt/crdt.gateway.ts b/server/src/crdt/crdt.gateway.ts index 9dc7d917..e975b957 100644 --- a/server/src/crdt/crdt.gateway.ts +++ b/server/src/crdt/crdt.gateway.ts @@ -76,7 +76,6 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa connectionTime: new Date(), }; this.clientMap.set(client.id, clientInfo); - console.log(userId, "유저아이디 체크"); client.emit("assign/clientId", assignedId); client.broadcast.emit("userJoined", { clientId: assignedId }); @@ -142,11 +141,18 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa throw new WsException(`Page with id ${pageId} not found`); } - // 페이지 데이터를 요청한 클라이언트에게 전송 - client.emit("join/page", { - pageId, - serializedPage: page.serialize(), - }); + const start = process.hrtime(); + const [seconds, nanoseconds] = process.hrtime(start); + this.logger.log( + `Page join operation took ${seconds}s ${nanoseconds / 1000000}ms\n` + + `Active connections: ${this.server.engine.clientsCount}\n` + + `Connected clients: ${this.clientMap.size}`, + ); + console.log(`Memory usage: ${process.memoryUsage().heapUsed}`), + client.emit("join/page", { + pageId, + serializedPage: page.serialize(), + }); this.logger.log(`Client ${clientInfo.clientId} joined page ${pageId}`); } catch (error) { @@ -309,7 +315,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ): Promise { const clientInfo = this.clientMap.get(client.id); try { - console.log("인서트 char", client.data.pageId); + console.log("인서트 char", data.pageId); this.logger.debug( `Insert 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), @@ -354,7 +360,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ): Promise { const clientInfo = this.clientMap.get(client.id); try { - console.log("딜리트 블록", client.data.pageId); + console.log("딜리트 블록", data.pageId); this.logger.debug( `Delete 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), @@ -392,7 +398,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ): Promise { const clientInfo = this.clientMap.get(client.id); try { - console.log("딜리트 캐릭터", client.data.pageId); + console.log("딜리트 캐릭터", data.pageId); this.logger.debug( `Delete 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), From 60aead762206f56030a02c168aabb3a748da6e26 Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Tue, 26 Nov 2024 02:47:18 +0900 Subject: [PATCH 15/18] =?UTF-8?q?feat:=20editorCRDT=20=EC=97=90=EB=94=94?= =?UTF-8?q?=ED=84=B0=EB=B3=84=20=EC=83=81=ED=83=9C=EB=A1=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 값이 변경될때 마다 리렌더가 일어나야함. - crdt props 수정. 아마 any로 될 확률 높음. - rootElement를 crdtRef로 만들어서 페이지 내부 캐럿이 다른 페이지로 튀는 현상 방지. - 단 현재는 내부 페이지에서 캐럿이 맨처음으로 초기화 되는 현상이 있음. - 추후 연규님 commit 과 합쳐서 현재 캐럿이 튀는 현상, 정상적으로 동작하지 않는현상 conflict 해결할 예정 #183 --- @noctaCrdt/Crdt.ts | 3 +- client/src/features/editor/Editor.tsx | 247 ++++++++++-------- .../editor/hooks/useMarkdownGrammer.ts | 5 + client/src/features/page/Page.tsx | 1 + client/src/utils/caretUtils.ts | 5 +- 5 files changed, 151 insertions(+), 110 deletions(-) diff --git a/@noctaCrdt/Crdt.ts b/@noctaCrdt/Crdt.ts index 674c25a0..7bca41b4 100644 --- a/@noctaCrdt/Crdt.ts +++ b/@noctaCrdt/Crdt.ts @@ -9,6 +9,7 @@ import { CRDTSerializedProps, RemoteBlockReorderOperation, RemoteBlockUpdateOperation, + serializedEditorDataProps, } from "./Interfaces"; export class CRDT> { @@ -190,7 +191,7 @@ export class EditorCRDT extends CRDT { this.clock = Math.max(this.clock, clock) + 1; } - serialize(): CRDTSerializedProps { + serialize(): serializedEditorDataProps { return { ...super.serialize(), currentBlock: this.currentBlock ? this.currentBlock.serialize() : null, diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index d2bc379e..b26ada37 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -1,39 +1,42 @@ import { DndContext } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { EditorCRDT } from "@noctaCrdt/Crdt"; +import { RemoteCharInsertOperation, serializedEditorDataProps } from "@noctaCrdt/Interfaces"; import { BlockLinkedList } from "@noctaCrdt/LinkedList"; import { Block as CRDTBlock } from "@noctaCrdt/Node"; import { BlockId } from "@noctaCrdt/NodeId"; -import { - RemoteCharInsertOperation, - serializedEditorDataProps, -} from "node_modules/@noctaCrdt/Interfaces.ts"; import { useRef, useState, useCallback, useEffect, useLayoutEffect } from "react"; -import { useSocketStore } from "@src/stores/useSocketStore.ts"; -import { setCaretPosition } from "@src/utils/caretUtils.ts"; +import { useSocketStore } from "@src/stores/useSocketStore"; +import { setCaretPosition } from "@src/utils/caretUtils"; import { editorContainer, editorTitleContainer, editorTitle, addNewBlockButton, } from "./Editor.style"; -import { Block } from "./components/block/Block.tsx"; +import { Block } from "./components/block/Block"; import { useBlockDragAndDrop } from "./hooks/useBlockDragAndDrop"; -import { useBlockOptionSelect } from "./hooks/useBlockOption.ts"; +import { useBlockOptionSelect } from "./hooks/useBlockOption"; import { useMarkdownGrammer } from "./hooks/useMarkdownGrammer"; interface EditorProps { onTitleChange: (title: string) => void; pageId: string; serializedEditorData: serializedEditorDataProps | null; + updatePageData: (pageId: string, newData: serializedEditorDataProps) => void; } export interface EditorStateProps { clock: number; linkedList: BlockLinkedList; } -// TODO: pageId, editorCRDT를 props로 받아와야함 -export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorProps) => { + +export const Editor = ({ + onTitleChange, + pageId, + serializedEditorData, + updatePageData, +}: EditorProps) => { const { sendCharInsertOperation, sendCharDeleteOperation, @@ -43,34 +46,38 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr sendBlockUpdateOperation, } = useSocketStore(); const { clientId } = useSocketStore(); + const editorRef = useRef(null); // Add ref for the editor + // editorCRDT를 useState로 관리하여 페이지별로 인스턴스를 분리 + const [editorCRDT, setEditorCRDT] = useState(null); - const editorCRDT = useRef( - (() => { - if (!serializedEditorData) { - return new EditorCRDT(clientId ? clientId : 0); - } - const editor = new EditorCRDT(serializedEditorData.client); - editor.deserialize(serializedEditorData); - return editor; - })(), - ); + useEffect(() => { + let newEditorCRDT; + if (serializedEditorData) { + newEditorCRDT = new EditorCRDT(serializedEditorData.client); + newEditorCRDT.deserialize(serializedEditorData); + } else { + newEditorCRDT = new EditorCRDT(clientId ? clientId : 0); + } + setEditorCRDT(newEditorCRDT); + }, [serializedEditorData, clientId]); - // Editor의 상태도 editorCRDT 기반으로 초기화 + // editorState도 editorCRDT가 변경될 때마다 업데이트 const [editorState, setEditorState] = useState({ - clock: editorCRDT.current.clock, - linkedList: editorCRDT.current.LinkedList, + clock: editorCRDT?.clock || 0, + linkedList: editorCRDT?.LinkedList || new BlockLinkedList(), }); - // editorCRDT가 변경될 때마다 editorState 업데이트 useEffect(() => { - setEditorState({ - clock: editorCRDT.current.clock, - linkedList: editorCRDT.current.LinkedList, - }); + if (editorCRDT) { + setEditorState({ + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, + }); + } }, [editorCRDT]); const { sensors, handleDragEnd } = useBlockDragAndDrop({ - editorCRDT: editorCRDT.current, + editorCRDT, editorState, setEditorState, pageId, @@ -78,7 +85,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr const { handleTypeSelect, handleAnimationSelect, handleCopySelect, handleDeleteSelect } = useBlockOptionSelect({ - editorCRDT: editorCRDT.current, + editorCRDT, editorState, setEditorState, pageId, @@ -89,7 +96,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr }); const { handleKeyDown } = useMarkdownGrammer({ - editorCRDT: editorCRDT.current, + editorCRDT, editorState, setEditorState, pageId, @@ -98,6 +105,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr sendBlockUpdateOperation, sendCharDeleteOperation, sendCharInsertOperation, + editorRef, }); const handleTitleChange = (e: React.ChangeEvent) => { @@ -105,13 +113,14 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr }; const handleBlockClick = (blockId: BlockId) => { - editorCRDT.current.currentBlock = - editorCRDT.current.LinkedList.nodeMap[JSON.stringify(blockId)]; + if (editorCRDT) { + editorCRDT.currentBlock = editorCRDT.LinkedList.nodeMap[JSON.stringify(blockId)]; + } }; const handleBlockInput = useCallback( (e: React.FormEvent, block: CRDTBlock) => { - if (!block) return; + if (!block || !editorCRDT) return; if ((e.nativeEvent as InputEvent).isComposing) { return; } @@ -128,34 +137,40 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr if (caretPosition === 0) { const [addedChar] = newContent; charNode = block.crdt.localInsert(0, addedChar, block.id, pageId); - editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; + editorCRDT.currentBlock = block; + editorCRDT.currentBlock.crdt.currentCaret = caretPosition; requestAnimationFrame(() => { setCaretPosition({ blockId: block.id, - linkedList: editorCRDT.current.LinkedList, + linkedList: editorCRDT.LinkedList, position: caretPosition, + rootElement: editorRef.current, }); }); } else if (caretPosition > currentContent.length) { const addedChar = newContent[newContent.length - 1]; charNode = block.crdt.localInsert(currentContent.length, addedChar, block.id, pageId); - editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; + editorCRDT.currentBlock = block; + editorCRDT.currentBlock.crdt.currentCaret = caretPosition; requestAnimationFrame(() => { setCaretPosition({ blockId: block.id, - linkedList: editorCRDT.current.LinkedList, + linkedList: editorCRDT.LinkedList, position: caretPosition, + rootElement: editorRef.current, }); }); } else { const addedChar = newContent[caretPosition - 1]; charNode = block.crdt.localInsert(caretPosition - 1, addedChar, block.id, pageId); - editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; + editorCRDT.currentBlock = block; + editorCRDT.currentBlock.crdt.currentCaret = caretPosition; requestAnimationFrame(() => { setCaretPosition({ blockId: block.id, - linkedList: editorCRDT.current.LinkedList, + linkedList: editorCRDT.LinkedList, position: caretPosition, + rootElement: editorRef.current, }); }); } @@ -164,121 +179,135 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr // 문자가 삭제된 경우 operationNode = block.crdt.localDelete(caretPosition, block.id, pageId); sendCharDeleteOperation(operationNode); - editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; + editorCRDT.currentBlock = block; + editorCRDT.currentBlock.crdt.currentCaret = caretPosition; requestAnimationFrame(() => { setCaretPosition({ blockId: block.id, - linkedList: editorCRDT.current.LinkedList, + linkedList: editorCRDT.LinkedList, position: caretPosition, + rootElement: editorRef.current, }); }); } setEditorState({ - clock: editorCRDT.current.clock, - linkedList: editorCRDT.current.LinkedList, + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, }); + + // 페이지 데이터 업데이트 + // updatePageData(pageId, editorCRDT.serialize()); }, - [sendCharInsertOperation, sendCharDeleteOperation], + [sendCharInsertOperation, sendCharDeleteOperation, editorCRDT, pageId, updatePageData], ); - const handleCompositionEnd = (e: React.CompositionEvent, block: CRDTBlock) => { - const event = e.nativeEvent as CompositionEvent; - const characters = [...event.data]; - const selection = window.getSelection(); - const caretPosition = selection?.focusOffset || 0; - const startPosition = caretPosition - characters.length; - - characters.forEach((char, index) => { - const insertPosition = startPosition + index; - const charNode = block.crdt.localInsert(insertPosition, char, block.id, pageId); - - sendCharInsertOperation({ - node: charNode.node, - blockId: block.id, - pageId, + const handleCompositionEnd = useCallback( + (e: React.CompositionEvent, block: CRDTBlock) => { + if (!editorCRDT) return; + const event = e.nativeEvent as CompositionEvent; + const characters = [...event.data]; + const selection = window.getSelection(); + const caretPosition = selection?.focusOffset || 0; + const startPosition = caretPosition - characters.length; + + characters.forEach((char, index) => { + const insertPosition = startPosition + index; + const charNode = block.crdt.localInsert(insertPosition, char, block.id, pageId); + + sendCharInsertOperation({ + node: charNode.node, + blockId: block.id, + pageId, + }); }); - }); - block.crdt.currentCaret = caretPosition; - }; + block.crdt.currentCaret = caretPosition; + updatePageData(pageId, editorCRDT.serialize()); + }, + [editorCRDT, pageId, sendCharInsertOperation, updatePageData], + ); const subscriptionRef = useRef(false); - useLayoutEffect(() => { - if (!editorCRDT || !editorCRDT.current.currentBlock) return; + useEffect(() => { + if (!editorCRDT || !editorCRDT.currentBlock) return; setCaretPosition({ - blockId: editorCRDT.current.currentBlock.id, - linkedList: editorCRDT.current.LinkedList, - position: editorCRDT.current.currentBlock?.crdt.currentCaret, + blockId: editorCRDT.currentBlock.id, + linkedList: editorCRDT.LinkedList, + position: editorCRDT.currentBlock?.crdt.currentCaret, + rootElement: editorRef.current, }); - }, [editorCRDT, editorCRDT?.current.currentBlock?.crdt.read().length]); + }, [editorCRDT, editorCRDT?.currentBlock?.crdt.read().length]); useEffect(() => { + if (!editorCRDT) return; if (subscriptionRef.current) return; subscriptionRef.current = true; const unsubscribe = subscribeToRemoteOperations({ onRemoteBlockInsert: (operation) => { console.log(operation, "block : 입력 확인합니다이"); - if (!editorCRDT || operation.pageId !== pageId) return; - editorCRDT.current.remoteInsert(operation); + if (operation.pageId !== pageId) return; + editorCRDT.remoteInsert(operation); setEditorState({ - clock: editorCRDT.current.clock, - linkedList: editorCRDT.current.LinkedList, + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, }); }, onRemoteBlockDelete: (operation) => { console.log(operation, "block : 삭제 확인합니다이"); - if (!editorCRDT || operation.pageId !== pageId) return; - editorCRDT.current.remoteDelete(operation); + if (operation.pageId !== pageId) return; + editorCRDT.remoteDelete(operation); setEditorState({ - clock: editorCRDT.current.clock, - linkedList: editorCRDT.current.LinkedList, + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, }); }, onRemoteCharInsert: (operation) => { console.log(operation, "char : 입력 확인합니다이"); - if (!editorCRDT || operation.pageId !== pageId) return; - const targetBlock = - editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; - targetBlock.crdt.remoteInsert(operation); - setEditorState({ - clock: editorCRDT.current.clock, - linkedList: editorCRDT.current.LinkedList, - }); + if (operation.pageId !== pageId) return; + const targetBlock = editorCRDT.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; + if (targetBlock) { + targetBlock.crdt.remoteInsert(operation); + setEditorState({ + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, + }); + } }, onRemoteCharDelete: (operation) => { console.log(operation, "char : 삭제 확인합니다이"); - if (!editorCRDT || operation.pageId !== pageId) return; - const targetBlock = - editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; - targetBlock.crdt.remoteDelete(operation); - setEditorState({ - clock: editorCRDT.current.clock, - linkedList: editorCRDT.current.LinkedList, - }); + if (operation.pageId !== pageId) return; + const targetBlock = editorCRDT.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; + if (targetBlock) { + targetBlock.crdt.remoteDelete(operation); + setEditorState({ + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, + }); + } }, onRemoteBlockUpdate: (operation) => { console.log(operation, "block : 업데이트 확인합니다이"); - if (!editorCRDT || operation.pageId !== pageId) return; - editorCRDT.current.remoteUpdate(operation.node, operation.pageId); + if (operation.pageId !== pageId) return; + editorCRDT.remoteUpdate(operation.node, operation.pageId); setEditorState({ - clock: editorCRDT.current.clock, - linkedList: editorCRDT.current.LinkedList, + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, }); }, onRemoteBlockReorder: (operation) => { console.log(operation, "block : 재정렬 확인합니다이"); - if (!editorCRDT || operation.pageId !== pageId) return; - editorCRDT.current.remoteReorder(operation); + if (operation.pageId !== pageId) return; + editorCRDT.remoteReorder(operation); setEditorState({ - clock: editorCRDT.current.clock, - linkedList: editorCRDT.current.LinkedList, + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, }); }, @@ -291,20 +320,22 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr subscriptionRef.current = false; unsubscribe?.(); }; - }, []); + }, [editorCRDT, subscribeToRemoteOperations, pageId]); const addNewBlock = () => { - const index = editorCRDT.current.LinkedList.spread().length; - const operation = editorCRDT.current.localInsert(index, ""); + if (!editorCRDT) return; + const index = editorCRDT.LinkedList.spread().length; + const operation = editorCRDT.localInsert(index, ""); sendBlockInsertOperation({ node: operation.node, pageId }); setEditorState(() => ({ - clock: operation.node.id.clock, - linkedList: editorCRDT.current.LinkedList, + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, })); + updatePageData(pageId, editorCRDT.serialize()); }; // 로딩 상태 체크 - if (!serializedEditorData) { + if (!editorCRDT || !editorState) { return
Loading editor data...
; } return ( @@ -328,7 +359,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr key={`${block.id.client}-${block.id.clock}`} id={`${block.id.client}-${block.id.clock}`} block={block} - isActive={block.id === editorCRDT.current.currentBlock?.id} + isActive={block.id === editorCRDT.currentBlock?.id} onInput={handleBlockInput} onCompositionEnd={handleCompositionEnd} onKeyDown={handleKeyDown} diff --git a/client/src/features/editor/hooks/useMarkdownGrammer.ts b/client/src/features/editor/hooks/useMarkdownGrammer.ts index d38ccbd9..18280b00 100644 --- a/client/src/features/editor/hooks/useMarkdownGrammer.ts +++ b/client/src/features/editor/hooks/useMarkdownGrammer.ts @@ -16,6 +16,7 @@ import { setCaretPosition } from "@src/utils/caretUtils"; interface useMarkdownGrammerProps { editorCRDT: EditorCRDT; editorState: EditorStateProps; + editorRef: React.RefObject; // Add editorRef setEditorState: React.Dispatch< React.SetStateAction<{ clock: number; @@ -40,6 +41,7 @@ export const useMarkdownGrammer = ({ sendCharDeleteOperation, sendCharInsertOperation, sendBlockUpdateOperation, + editorRef, }: useMarkdownGrammerProps) => { const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -286,6 +288,7 @@ export const useMarkdownGrammer = ({ blockId: targetBlock.id, linkedList: editorCRDT.LinkedList, position: Math.min(caretPosition, targetBlock.crdt.read().length), + rootElement: editorRef.current, }); break; } @@ -306,6 +309,7 @@ export const useMarkdownGrammer = ({ blockId: prevBlock.id, linkedList: editorCRDT.LinkedList, position: prevBlock.crdt.read().length, + rootElement: editorRef.current, }); } break; @@ -324,6 +328,7 @@ export const useMarkdownGrammer = ({ blockId: nextBlock.id, linkedList: editorCRDT.LinkedList, position: 0, + rootElement: editorRef.current, }); } break; diff --git a/client/src/features/page/Page.tsx b/client/src/features/page/Page.tsx index 71eba5e7..006563d9 100644 --- a/client/src/features/page/Page.tsx +++ b/client/src/features/page/Page.tsx @@ -75,6 +75,7 @@ export const Page = ({ onTitleChange={onTitleChange} pageId={id} serializedEditorData={serializedEditorDatas} + updatePageData={updatePageData} /> {DIRECTIONS.map((direction) => ( { try { + if (!rootElement) return false; // Ensure rootElement is provided const selection = window.getSelection(); if (!selection) return false; From 301046de917ec13e1b52e40467cf917def88a762 Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Tue, 26 Nov 2024 02:53:21 +0900 Subject: [PATCH 16/18] =?UTF-8?q?chore:=20lint=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 --- client/src/features/editor/Editor.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index b26ada37..60f2d1d0 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -5,7 +5,7 @@ import { RemoteCharInsertOperation, serializedEditorDataProps } from "@noctaCrdt import { BlockLinkedList } from "@noctaCrdt/LinkedList"; import { Block as CRDTBlock } from "@noctaCrdt/Node"; import { BlockId } from "@noctaCrdt/NodeId"; -import { useRef, useState, useCallback, useEffect, useLayoutEffect } from "react"; +import { useRef, useState, useCallback, useEffect } from "react"; import { useSocketStore } from "@src/stores/useSocketStore"; import { setCaretPosition } from "@src/utils/caretUtils"; import { @@ -48,7 +48,7 @@ export const Editor = ({ const { clientId } = useSocketStore(); const editorRef = useRef(null); // Add ref for the editor // editorCRDT를 useState로 관리하여 페이지별로 인스턴스를 분리 - const [editorCRDT, setEditorCRDT] = useState(null); + const [editorCRDT, setEditorCRDT] = useState(() => new EditorCRDT(0)); useEffect(() => { let newEditorCRDT; From e31faa4ba45179b1f63f97915987e96680f94f7c Mon Sep 17 00:00:00 2001 From: hyonun321 Date: Tue, 26 Nov 2024 11:17:26 +0900 Subject: [PATCH 17/18] =?UTF-8?q?chore:=20=EB=B3=91=ED=95=A9=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- @noctaCrdt/Crdt.ts | 2 +- client/src/features/editor/Editor.tsx | 28 +- .../editor/hooks/useMarkdownGrammer.ts | 274 +----------------- server/src/crdt/crdt.gateway.ts | 8 +- 4 files changed, 15 insertions(+), 297 deletions(-) diff --git a/@noctaCrdt/Crdt.ts b/@noctaCrdt/Crdt.ts index 04abd400..c6a29bf7 100644 --- a/@noctaCrdt/Crdt.ts +++ b/@noctaCrdt/Crdt.ts @@ -135,7 +135,7 @@ export class EditorCRDT extends CRDT { newNode.next = operation.node.next; newNode.prev = operation.node.prev; - + newNode.indent = operation.node.indent; this.LinkedList.insertById(newNode); this.clock = Math.max(this.clock, operation.node.id.clock) + 1; diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index f6e15de2..3df05bcd 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -8,7 +8,7 @@ import { RemoteCharInsertOperation, serializedEditorDataProps, } from "node_modules/@noctaCrdt/Interfaces.ts"; -import { useRef, useState, useCallback, useEffect, useMemo } from "react"; +import { useRef, useState, useCallback, useEffect } from "react"; import { useSocketStore } from "@src/stores/useSocketStore.ts"; import { setCaretPosition, getAbsoluteCaretPosition } from "@src/utils/caretUtils.ts"; import { @@ -134,12 +134,11 @@ export const Editor = ({ ) as HTMLDivElement; if (!clickedElement) return; - editorCRDT.current.currentBlock = - editorCRDT.current.LinkedList.nodeMap[JSON.stringify(blockId)]; + editorCRDT.currentBlock = editorCRDT.LinkedList.nodeMap[JSON.stringify(blockId)]; const caretPosition = getAbsoluteCaretPosition(clickedElement); // 계산된 캐럿 위치 저장 - editorCRDT.current.currentBlock.crdt.currentCaret = caretPosition; + editorCRDT.currentBlock.crdt.currentCaret = caretPosition; } }; @@ -189,7 +188,7 @@ export const Editor = ({ sendCharDeleteOperation(operationNode); // 캐럿 위치 업데이트 - editorCRDT.current.currentBlock!.crdt.currentCaret = deletePosition; + editorCRDT.currentBlock!.crdt.currentCaret = deletePosition; } } setEditorState({ @@ -316,9 +315,8 @@ export const Editor = ({ onRemoteCharUpdate: (operation) => { console.log(operation, "char : 업데이트 확인합니다이"); - if (!editorCRDT.current) return; - const targetBlock = - editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; + if (!editorCRDT) return; + const targetBlock = editorCRDT.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; targetBlock.crdt.remoteUpdate(operation); setEditorState({ clock: editorCRDT.clock, @@ -326,18 +324,6 @@ export const Editor = ({ }); }, - onRemoteCharUpdate: (operation) => { - console.log(operation, "char : 업데이트 확인합니다이"); - if (!editorCRDT.current) return; - const targetBlock = - editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; - targetBlock.crdt.remoteUpdate(operation); - setEditorState({ - clock: editorCRDT.current.clock, - linkedList: editorCRDT.current.LinkedList, - }); - }, - onRemoteCursor: (position) => { console.log(position, "커서위치 수신"); }, @@ -353,7 +339,7 @@ export const Editor = ({ if (!editorCRDT) return; const index = editorCRDT.LinkedList.spread().length; const operation = editorCRDT.localInsert(index, ""); - editorCRDT.current.currentBlock = operation.node; + editorCRDT.currentBlock = operation.node; sendBlockInsertOperation({ node: operation.node, pageId }); setEditorState(() => ({ clock: editorCRDT.clock, diff --git a/client/src/features/editor/hooks/useMarkdownGrammer.ts b/client/src/features/editor/hooks/useMarkdownGrammer.ts index 036c44f8..0e84f161 100644 --- a/client/src/features/editor/hooks/useMarkdownGrammer.ts +++ b/client/src/features/editor/hooks/useMarkdownGrammer.ts @@ -300,6 +300,7 @@ export const useMarkdownGrammer = ({ blockId: targetBlock.id, linkedList: editorCRDT.LinkedList, position: Math.min(caretPosition, targetBlock.crdt.read().length), + rootElement: editorRef.current, }); break; } @@ -321,6 +322,7 @@ export const useMarkdownGrammer = ({ blockId: prevBlock.id, linkedList: editorCRDT.LinkedList, position: prevBlock.crdt.read().length, + rootElement: editorRef.current, }); } break; @@ -339,6 +341,7 @@ export const useMarkdownGrammer = ({ blockId: nextBlock.id, linkedList: editorCRDT.LinkedList, position: 0, + rootElement: editorRef.current, }); } break; @@ -360,274 +363,3 @@ export const useMarkdownGrammer = ({ return { handleKeyDown }; }; - -/* -switch (e.key) { - case "Enter": { - e.preventDefault(); - const selection = window.getSelection(); - if (!selection) return; - const caretPosition = selection.focusOffset; - const currentContent = currentBlock.crdt.read(); - const afterText = currentContent.slice(caretPosition); - - if (!currentContent && currentBlock.type !== "p") { - currentBlock.type = "p"; - sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); - editorCRDT.currentBlock = currentBlock; - updateEditorState(); - break; - } - - if (!currentContent && currentBlock.type === "p") { - // 새로운 기본 블록 생성 - const operation = createNewBlock(currentIndex + 1); - operation.node.indent = currentBlock.indent; - operation.node.crdt = new BlockCRDT(editorCRDT.client); - - sendBlockInsertOperation({ node: operation.node, pageId }); - updateEditorState(operation.node.id); - break; - } - - // 현재 캐럿 위치 이후의 텍스트가 있으면 현재 블록 내용 업데이트 - if (afterText) { - // 캐럿 이후의 텍스트만 제거 - for (let i = currentContent.length - 1; i >= caretPosition; i--) { - sendCharDeleteOperation(currentBlock.crdt.localDelete(i, currentBlock.id, pageId)); - } - } - - // 새 블록 생성 - const operation = createNewBlock(currentIndex + 1); - operation.node.crdt = new BlockCRDT(editorCRDT.client); - operation.node.indent = currentBlock.indent; - sendBlockInsertOperation({ node: operation.node, pageId }); - // 캐럿 이후의 텍스트 있으면 새 블록에 추가 - if (afterText) { - afterText.split("").forEach((char, i) => { - sendCharInsertOperation( - operation.node.crdt.localInsert(i, char, operation.node.id, pageId), - ); - }); - } - - // 현재 블록이 li나 checkbox면 동일한 타입으로 생성 - if (["ul", "ol", "checkbox"].includes(currentBlock.type)) { - operation.node.type = currentBlock.type; - sendBlockUpdateOperation(editorCRDT.localUpdate(operation.node, pageId)); - } - updateEditorState(operation.node.id); - break; - } - - case "Backspace": { - const selection = window.getSelection(); - const caretPosition = selection?.focusOffset || 0; - const currentContent = currentBlock.crdt.read(); - if (currentContent === "") { - e.preventDefault(); - if (currentBlock.indent > 0) { - currentBlock.indent -= 1; - sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); - editorCRDT.currentBlock = currentBlock; - updateEditorState(); - break; - } - - if (currentBlock.type !== "p") { - // 마지막 블록이면 기본 블록으로 변경 - currentBlock.type = "p"; - sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); - editorCRDT.currentBlock = currentBlock; - updateEditorState(); - break; - } - - const prevBlock = - currentIndex > 0 ? editorCRDT.LinkedList.findByIndex(currentIndex - 1) : null; - if (prevBlock) { - sendBlockDeleteOperation(editorCRDT.localDelete(currentIndex, undefined, pageId)); - prevBlock.crdt.currentCaret = prevBlock.crdt.read().length; - editorCRDT.currentBlock = prevBlock; - updateEditorState(prevBlock.id); - } - break; - } else { - if (caretPosition === 0) { - if (currentBlock.indent > 0) { - currentBlock.indent -= 1; - sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); - editorCRDT.currentBlock = currentBlock; - updateEditorState(); - break; - } - if (currentBlock.type !== "p") { - currentBlock.type = "p"; - sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); - editorCRDT.currentBlock = currentBlock; - updateEditorState(); - // FIX: 서윤님 피드백 반영 - } else { - const prevBlock = - currentIndex > 0 ? editorCRDT.LinkedList.findByIndex(currentIndex - 1) : null; - if (prevBlock) { - const prevBlockEndCaret = prevBlock.crdt.read().length; - currentContent.split("").forEach((char) => { - sendCharInsertOperation( - prevBlock.crdt.localInsert( - prevBlock.crdt.read().length, - char, - prevBlock.id, - pageId, - ), - ); - sendCharDeleteOperation( - currentBlock.crdt.localDelete(caretPosition, currentBlock.id, pageId), - ); - }); - prevBlock.crdt.currentCaret = prevBlockEndCaret; - sendBlockDeleteOperation(editorCRDT.localDelete(currentIndex, undefined, pageId)); - updateEditorState(prevBlock.id); - e.preventDefault(); - } - } - } - break; - } - } - - case "Tab": { - e.preventDefault(); - - if (currentBlock) { - if (e.shiftKey) { - // shift + tab: 들여쓰기 감소 - if (currentBlock.indent > 0) { - currentBlock.indent -= 1; - sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); - editorCRDT.currentBlock = currentBlock; - updateEditorState(); - } - } else { - // tab: 들여쓰기 증가 - const maxIndent = 3; - if (currentBlock.indent < maxIndent) { - currentBlock.indent += 1; - sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); - editorCRDT.currentBlock = currentBlock; - updateEditorState(); - } - } - } - break; - } - - case " ": { - // 여기 수정함 - const selection = window.getSelection(); - if (!selection) return; - const currentContent = currentBlock.crdt.read(); - const markdownElement = checkMarkdownPattern(currentContent); - if (markdownElement && currentBlock.type === "p") { - e.preventDefault(); - // 마크다운 패턴 매칭 시 타입 변경하고 내용 비우기 - currentBlock.type = markdownElement.type; - let deleteCount = 0; - while (deleteCount < markdownElement.length) { - sendCharDeleteOperation(currentBlock.crdt.localDelete(0, currentBlock.id, pageId)); - deleteCount += 1; - } - sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); - currentBlock.crdt.currentCaret = 0; - editorCRDT.currentBlock = currentBlock; - updateEditorState(); - } - - break; - } - - case "ArrowUp": - case "ArrowDown": { - const hasPrevBlock = currentIndex > 0; - const hasNextBlock = currentIndex < editorCRDT.LinkedList.spread().length - 1; - if (e.key === "ArrowUp" && !hasPrevBlock) { - e.preventDefault(); - return; - } - if (e.key === "ArrowDown" && !hasNextBlock) { - e.preventDefault(); - return; - } - - const selection = window.getSelection(); - const caretPosition = selection?.focusOffset || 0; - - // 이동할 블록 결정 - const targetIndex = e.key === "ArrowUp" ? currentIndex - 1 : currentIndex + 1; - const targetBlock = editorCRDT.LinkedList.findByIndex(targetIndex); - if (!targetBlock) return; - e.preventDefault(); - targetBlock.crdt.currentCaret = Math.min(caretPosition, targetBlock.crdt.read().length); - editorCRDT.currentBlock = targetBlock; - setCaretPosition({ - blockId: targetBlock.id, - linkedList: editorCRDT.LinkedList, - position: Math.min(caretPosition, targetBlock.crdt.read().length), - rootElement: editorRef.current, - }); - break; - } - case "ArrowLeft": - case "ArrowRight": { - const selection = window.getSelection(); - const caretPosition = selection?.focusOffset || 0; - const textLength = currentBlock.crdt.read().length; - - // 왼쪽 끝에서 이전 블록으로 - if (e.key === "ArrowLeft" && caretPosition === 0 && currentIndex > 0) { - e.preventDefault(); // 기본 동작 방지 - const prevBlock = editorCRDT.LinkedList.findByIndex(currentIndex - 1); - if (prevBlock) { - prevBlock.crdt.currentCaret = prevBlock.crdt.read().length; - editorCRDT.currentBlock = prevBlock; - setCaretPosition({ - blockId: prevBlock.id, - linkedList: editorCRDT.LinkedList, - position: prevBlock.crdt.read().length, - rootElement: editorRef.current, - }); - } - break; - // 오른쪽 끝에서 다음 블록으로 - } else if ( - e.key === "ArrowRight" && - caretPosition === textLength && - currentIndex < editorCRDT.LinkedList.spread().length - 1 - ) { - e.preventDefault(); // 기본 동작 방지 - const nextBlock = editorState.linkedList.findByIndex(currentIndex + 1); - if (nextBlock) { - nextBlock.crdt.currentCaret = 0; - editorCRDT.currentBlock = nextBlock; - setCaretPosition({ - blockId: nextBlock.id, - linkedList: editorCRDT.LinkedList, - position: 0, - rootElement: editorRef.current, - }); - } - break; - // 블록 내에서 이동하는 경우 - } else { - if (e.key === "ArrowLeft") { - currentBlock.crdt.currentCaret -= 1; - } else { - currentBlock.crdt.currentCaret += 1; - } - } - - break; - } - } - */ diff --git a/server/src/crdt/crdt.gateway.ts b/server/src/crdt/crdt.gateway.ts index d5960c88..a5833e78 100644 --- a/server/src/crdt/crdt.gateway.ts +++ b/server/src/crdt/crdt.gateway.ts @@ -245,9 +245,9 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa `Page delete 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), ); - + const { userId } = client.data; // 현재 워크스페이스 가져오기 - const currentWorkspace = this.workSpaceService.getWorkspace(); + const currentWorkspace = this.workSpaceService.getWorkspace(userId); // pageList에서 해당 페이지 찾기 const pageIndex = currentWorkspace.pageList.findIndex((page) => page.id === data.pageId); @@ -531,9 +531,9 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa `Update 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), ); - + const { userId } = client.data; const currentPage = this.workSpaceService - .getWorkspace() + .getWorkspace(userId) .pageList.find((p) => p.id === data.pageId); if (!currentPage) { throw new Error(`Page with id ${data.pageId} not found`); From 8cc6dc30fd79d5f26e34c145bfa4bdef323c7085 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Tue, 26 Nov 2024 13:07:37 +0900 Subject: [PATCH 18/18] =?UTF-8?q?fix:=20=EB=B3=91=ED=95=A9=EA=B3=BC?= =?UTF-8?q?=EC=A0=95=EC=97=90=EC=84=9C=20=EC=83=9D=EA=B8=B4=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- @noctaCrdt/Crdt.ts | 3 + client/src/features/editor/Editor.tsx | 132 +++++++++--------- .../editor/hooks/useMarkdownGrammer.ts | 10 +- client/src/features/page/Page.tsx | 1 + client/src/utils/caretUtils.ts | 10 +- server/src/crdt/crdt.gateway.ts | 3 +- 6 files changed, 79 insertions(+), 80 deletions(-) diff --git a/@noctaCrdt/Crdt.ts b/@noctaCrdt/Crdt.ts index d4b8e0e1..db62dd79 100644 --- a/@noctaCrdt/Crdt.ts +++ b/@noctaCrdt/Crdt.ts @@ -242,6 +242,8 @@ export class BlockCRDT extends CRDT { blockId, pageId, style: node.style || [], + color: node.color, + backgroundColor: node.backgroundColor, }; return operation; @@ -325,6 +327,7 @@ export class BlockCRDT extends CRDT { remoteUpdate(operation: RemoteCharUpdateOperation): void { const updatedChar = this.LinkedList.nodeMap[JSON.stringify(operation.node.id)]; + console.log("remoteUpdate", updatedChar); if (operation.node.style && operation.node.style.length > 0) { updatedChar.style = [...operation.node.style]; } diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index f73c0fd9..38860de9 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -10,7 +10,7 @@ import { TextColorType, BackgroundColorType, } from "node_modules/@noctaCrdt/Interfaces.ts"; -import { useRef, useState, useCallback, useEffect } from "react"; +import { useRef, useState, useCallback, useEffect, useMemo } from "react"; import { useSocketStore } from "@src/stores/useSocketStore.ts"; import { setCaretPosition, getAbsoluteCaretPosition } from "@src/utils/caretUtils.ts"; import { @@ -58,11 +58,8 @@ export const Editor = ({ sendBlockUpdateOperation, } = useSocketStore(); const { clientId } = useSocketStore(); - const editorRef = useRef(null); // Add ref for the editor - // editorCRDT를 useState로 관리하여 페이지별로 인스턴스를 분리 - const [editorCRDT, setEditorCRDT] = useState(() => new EditorCRDT(0)); - useEffect(() => { + const editorCRDTInstance = useMemo(() => { let newEditorCRDT; if (serializedEditorData) { newEditorCRDT = new EditorCRDT(serializedEditorData.client); @@ -70,26 +67,19 @@ export const Editor = ({ } else { newEditorCRDT = new EditorCRDT(clientId ? clientId : 0); } - setEditorCRDT(newEditorCRDT); + return newEditorCRDT; }, [serializedEditorData, clientId]); + const editorCRDT = useRef(editorCRDTInstance); + // editorState도 editorCRDT가 변경될 때마다 업데이트 const [editorState, setEditorState] = useState({ - clock: editorCRDT?.clock || 0, - linkedList: editorCRDT?.LinkedList || new BlockLinkedList(), + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, }); - useEffect(() => { - if (editorCRDT) { - setEditorState({ - clock: editorCRDT.clock, - linkedList: editorCRDT.LinkedList, - }); - } - }, [editorCRDT]); - const { sensors, handleDragEnd } = useBlockDragAndDrop({ - editorCRDT, + editorCRDT: editorCRDT.current, editorState, setEditorState, pageId, @@ -97,7 +87,7 @@ export const Editor = ({ const { handleTypeSelect, handleAnimationSelect, handleCopySelect, handleDeleteSelect } = useBlockOptionSelect({ - editorCRDT, + editorCRDT: editorCRDT.current, editorState, setEditorState, pageId, @@ -108,7 +98,7 @@ export const Editor = ({ }); const { handleKeyDown: onKeyDown } = useMarkdownGrammer({ - editorCRDT, + editorCRDT: editorCRDT.current, editorState, setEditorState, pageId, @@ -117,12 +107,11 @@ export const Editor = ({ sendBlockUpdateOperation, sendCharDeleteOperation, sendCharInsertOperation, - editorRef, }); const { onTextStyleUpdate, onTextColorUpdate, onTextBackgroundColorUpdate } = useTextOptionSelect( { - editorCRDT, + editorCRDT: editorCRDT.current, setEditorState, pageId, }, @@ -142,11 +131,12 @@ export const Editor = ({ ) as HTMLDivElement; if (!clickedElement) return; - editorCRDT.currentBlock = editorCRDT.LinkedList.nodeMap[JSON.stringify(blockId)]; + editorCRDT.current.currentBlock = + editorCRDT.current.LinkedList.nodeMap[JSON.stringify(blockId)]; const caretPosition = getAbsoluteCaretPosition(clickedElement); // 계산된 캐럿 위치 저장 - editorCRDT.currentBlock.crdt.currentCaret = caretPosition; + editorCRDT.current.currentBlock.crdt.currentCaret = caretPosition; } }; @@ -185,7 +175,7 @@ export const Editor = ({ const addedChar = newContent[validCaretPosition - 1]; charNode = block.crdt.localInsert(validCaretPosition - 1, addedChar, block.id, pageId); } - editorCRDT.currentBlock!.crdt.currentCaret = caretPosition; + editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition; sendCharInsertOperation({ node: charNode.node, blockId: block.id, pageId }); } else if (newContent.length < currentContent.length) { // 문자가 삭제된 경우 @@ -196,12 +186,12 @@ export const Editor = ({ sendCharDeleteOperation(operationNode); // 캐럿 위치 업데이트 - editorCRDT.currentBlock!.crdt.currentCaret = deletePosition; + editorCRDT.current.currentBlock!.crdt.currentCaret = deletePosition; } } setEditorState({ - clock: editorCRDT.clock, - linkedList: editorCRDT.LinkedList, + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, }); }, [sendCharInsertOperation, sendCharDeleteOperation, editorCRDT, pageId, updatePageData], @@ -237,8 +227,8 @@ export const Editor = ({ block.crdt.currentCaret = startOffset; setEditorState({ - clock: editorCRDT.clock, - linkedList: editorCRDT.LinkedList, + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, }); } else { onKeyDown(e); @@ -315,7 +305,7 @@ export const Editor = ({ }); }); - editorCRDT.currentBlock!.crdt.currentCaret = caretPosition + metadata.length; + editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition + metadata.length; } else { const text = e.clipboardData.getData("text/plain"); @@ -335,12 +325,12 @@ export const Editor = ({ }); // 캐럿 위치 업데이트 - editorCRDT.currentBlock!.crdt.currentCaret = caretPosition + text.length; + editorCRDT.current.currentBlock!.crdt.currentCaret = caretPosition + text.length; } setEditorState({ - clock: editorCRDT.clock, - linkedList: editorCRDT.LinkedList, + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, }); }; @@ -365,7 +355,7 @@ export const Editor = ({ }); block.crdt.currentCaret = caretPosition; - updatePageData(pageId, editorCRDT.serialize()); + updatePageData(pageId, editorCRDT.current.serialize()); }, [editorCRDT, pageId, sendCharInsertOperation, updatePageData], ); @@ -373,15 +363,15 @@ export const Editor = ({ const subscriptionRef = useRef(false); useEffect(() => { - if (!editorCRDT || !editorCRDT.currentBlock) return; + if (!editorCRDT || !editorCRDT.current.currentBlock) return; setCaretPosition({ - blockId: editorCRDT.currentBlock.id, - linkedList: editorCRDT.LinkedList, - position: editorCRDT.currentBlock?.crdt.currentCaret, - rootElement: editorRef.current, + blockId: editorCRDT.current.currentBlock.id, + linkedList: editorCRDT.current.LinkedList, + position: editorCRDT.current.currentBlock?.crdt.currentCaret, + pageId, }); // 서윤님 피드백 반영 - }, [editorCRDT, editorCRDT?.currentBlock?.id.serialize()]); + }, [editorCRDT.current.currentBlock?.id.serialize()]); useEffect(() => { if (!editorCRDT) return; @@ -392,32 +382,33 @@ export const Editor = ({ onRemoteBlockInsert: (operation) => { console.log(operation, "block : 입력 확인합니다이"); if (operation.pageId !== pageId) return; - editorCRDT.remoteInsert(operation); + editorCRDT.current.remoteInsert(operation); setEditorState({ - clock: editorCRDT.clock, - linkedList: editorCRDT.LinkedList, + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, }); }, onRemoteBlockDelete: (operation) => { console.log(operation, "block : 삭제 확인합니다이"); if (operation.pageId !== pageId) return; - editorCRDT.remoteDelete(operation); + editorCRDT.current.remoteDelete(operation); setEditorState({ - clock: editorCRDT.clock, - linkedList: editorCRDT.LinkedList, + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, }); }, onRemoteCharInsert: (operation) => { console.log(operation, "char : 입력 확인합니다이"); if (operation.pageId !== pageId) return; - const targetBlock = editorCRDT.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; + const targetBlock = + editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; if (targetBlock) { targetBlock.crdt.remoteInsert(operation); setEditorState({ - clock: editorCRDT.clock, - linkedList: editorCRDT.LinkedList, + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, }); } }, @@ -425,12 +416,13 @@ export const Editor = ({ onRemoteCharDelete: (operation) => { console.log(operation, "char : 삭제 확인합니다이"); if (operation.pageId !== pageId) return; - const targetBlock = editorCRDT.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; + const targetBlock = + editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; if (targetBlock) { targetBlock.crdt.remoteDelete(operation); setEditorState({ - clock: editorCRDT.clock, - linkedList: editorCRDT.LinkedList, + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, }); } }, @@ -438,31 +430,33 @@ export const Editor = ({ onRemoteBlockUpdate: (operation) => { console.log(operation, "block : 업데이트 확인합니다이"); if (operation.pageId !== pageId) return; - editorCRDT.remoteUpdate(operation.node, operation.pageId); + editorCRDT.current.remoteUpdate(operation.node, operation.pageId); setEditorState({ - clock: editorCRDT.clock, - linkedList: editorCRDT.LinkedList, + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, }); }, onRemoteBlockReorder: (operation) => { console.log(operation, "block : 재정렬 확인합니다이"); if (operation.pageId !== pageId) return; - editorCRDT.remoteReorder(operation); + editorCRDT.current.remoteReorder(operation); setEditorState({ - clock: editorCRDT.clock, - linkedList: editorCRDT.LinkedList, + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, }); }, onRemoteCharUpdate: (operation) => { console.log(operation, "char : 업데이트 확인합니다이"); if (!editorCRDT) return; - const targetBlock = editorCRDT.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; + if (operation.pageId !== pageId) return; + const targetBlock = + editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; targetBlock.crdt.remoteUpdate(operation); setEditorState({ - clock: editorCRDT.clock, - linkedList: editorCRDT.LinkedList, + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, }); }, @@ -479,15 +473,15 @@ export const Editor = ({ const addNewBlock = () => { if (!editorCRDT) return; - const index = editorCRDT.LinkedList.spread().length; - const operation = editorCRDT.localInsert(index, ""); - editorCRDT.currentBlock = operation.node; + const index = editorCRDT.current.LinkedList.spread().length; + const operation = editorCRDT.current.localInsert(index, ""); + editorCRDT.current.currentBlock = operation.node; sendBlockInsertOperation({ node: operation.node, pageId }); setEditorState(() => ({ - clock: editorCRDT.clock, - linkedList: editorCRDT.LinkedList, + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, })); - updatePageData(pageId, editorCRDT.serialize()); + updatePageData(pageId, editorCRDT.current.serialize()); }; // 로딩 상태 체크 @@ -516,7 +510,7 @@ export const Editor = ({ key={`${block.id.client}-${block.id.clock}`} id={`${block.id.client}-${block.id.clock}`} block={block} - isActive={block.id === editorCRDT.currentBlock?.id} + isActive={block.id === editorCRDT.current.currentBlock?.id} onInput={handleBlockInput} onCompositionEnd={handleCompositionEnd} onKeyDown={handleKeyDown} diff --git a/client/src/features/editor/hooks/useMarkdownGrammer.ts b/client/src/features/editor/hooks/useMarkdownGrammer.ts index 0e84f161..cf23ff73 100644 --- a/client/src/features/editor/hooks/useMarkdownGrammer.ts +++ b/client/src/features/editor/hooks/useMarkdownGrammer.ts @@ -14,8 +14,7 @@ import { setCaretPosition, getAbsoluteCaretPosition } from "@src/utils/caretUtil interface useMarkdownGrammerProps { editorCRDT: EditorCRDT; - editorState: EditorStateProps; - editorRef: React.RefObject; // Add editorRef + editorState: EditorStateProps; // Add editorRef setEditorState: React.Dispatch< React.SetStateAction<{ clock: number; @@ -40,7 +39,6 @@ export const useMarkdownGrammer = ({ sendCharDeleteOperation, sendCharInsertOperation, sendBlockUpdateOperation, - editorRef, }: useMarkdownGrammerProps) => { const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -300,7 +298,7 @@ export const useMarkdownGrammer = ({ blockId: targetBlock.id, linkedList: editorCRDT.LinkedList, position: Math.min(caretPosition, targetBlock.crdt.read().length), - rootElement: editorRef.current, + pageId, }); break; } @@ -322,7 +320,7 @@ export const useMarkdownGrammer = ({ blockId: prevBlock.id, linkedList: editorCRDT.LinkedList, position: prevBlock.crdt.read().length, - rootElement: editorRef.current, + pageId, }); } break; @@ -341,7 +339,7 @@ export const useMarkdownGrammer = ({ blockId: nextBlock.id, linkedList: editorCRDT.LinkedList, position: 0, - rootElement: editorRef.current, + pageId, }); } break; diff --git a/client/src/features/page/Page.tsx b/client/src/features/page/Page.tsx index 006563d9..3434a811 100644 --- a/client/src/features/page/Page.tsx +++ b/client/src/features/page/Page.tsx @@ -54,6 +54,7 @@ export const Page = ({ return (
{ @@ -76,17 +76,19 @@ export const setCaretPosition = ({ blockId, linkedList, position, - rootElement, + pageId, }: SetCaretPositionProps): void => { try { - if (!rootElement) return; if (position === undefined) return; const selection = window.getSelection(); if (!selection) return; + const currentPage = document.getElementById(pageId); + const blockElements = Array.from( - document.querySelectorAll('.d_flex.pos_relative.w_full[data-group="true"]'), + currentPage?.querySelectorAll('.d_flex.pos_relative.w_full[data-group="true"]') || [], ); + const currentIndex = linkedList.spread().findIndex((b) => b.id === blockId); const targetElement = blockElements[currentIndex]; if (!targetElement) return; diff --git a/server/src/crdt/crdt.gateway.ts b/server/src/crdt/crdt.gateway.ts index ba351676..e3f8276d 100644 --- a/server/src/crdt/crdt.gateway.ts +++ b/server/src/crdt/crdt.gateway.ts @@ -307,7 +307,8 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa node: data.node, pageId: data.pageId, } as RemoteBlockUpdateOperation; - client.to(client.data.pageId).emit("update/block", operation); + // 여기서 문제가? + client.to(data.pageId).emit("update/block", operation); } catch (error) { this.logger.error( `블록 Update 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`,