diff --git a/@noctaCrdt/Crdt.ts b/@noctaCrdt/Crdt.ts index 737ad9fe..db62dd79 100644 --- a/@noctaCrdt/Crdt.ts +++ b/@noctaCrdt/Crdt.ts @@ -9,6 +9,7 @@ import { CRDTSerializedProps, RemoteBlockReorderOperation, RemoteBlockUpdateOperation, + serializedEditorDataProps, RemoteCharUpdateOperation, TextColorType, BackgroundColorType, @@ -134,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; @@ -193,7 +194,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, @@ -241,6 +242,8 @@ export class BlockCRDT extends CRDT { blockId, pageId, style: node.style || [], + color: node.color, + backgroundColor: node.backgroundColor, }; return operation; @@ -324,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/App.tsx b/client/src/App.tsx index 8b120498..5462bfa0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,22 +1,23 @@ -import { useErrorStore } from "@stores/useErrorStore"; 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(); + socketStore.init(id); return () => { setTimeout(() => { socketStore.cleanup(); }, 0); }; - }, []); + }, [id]); return ( <> diff --git a/client/src/components/sidebar/Sidebar.tsx b/client/src/components/sidebar/Sidebar.tsx index c92fe3f5..3f5bdc8f 100644 --- a/client/src/components/sidebar/Sidebar.tsx +++ b/client/src/components/sidebar/Sidebar.tsx @@ -22,11 +22,11 @@ import { 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; @@ -43,7 +43,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 962c29ff..38860de9 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -19,9 +19,9 @@ import { 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"; import { useTextOptionSelect } from "./hooks/useTextOptions.ts"; import { getTextOffset } from "./utils/domSyncUtils.ts"; @@ -35,17 +35,20 @@ interface EditorProps { onTitleChange: (title: string) => void; pageId: string; serializedEditorData: serializedEditorDataProps; + updatePageData: (pageId: string, newData: serializedEditorDataProps) => void; } - interface ClipboardMetadata { value: string; style: string[]; color: TextColorType | undefined; backgroundColor: BackgroundColorType | undefined; } - -// TODO: pageId, editorCRDT를 props로 받아와야함 -export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorProps) => { +export const Editor = ({ + onTitleChange, + pageId, + serializedEditorData, + updatePageData, +}: EditorProps) => { const { sendCharInsertOperation, sendCharDeleteOperation, @@ -54,17 +57,27 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr sendBlockDeleteOperation, sendBlockUpdateOperation, } = useSocketStore(); + const { clientId } = useSocketStore(); + const editorCRDTInstance = useMemo(() => { - const editor = new EditorCRDT(serializedEditorData.client); - editor.deserialize(serializedEditorData); - return editor; - }, [serializedEditorData]); + let newEditorCRDT; + if (serializedEditorData) { + newEditorCRDT = new EditorCRDT(serializedEditorData.client); + newEditorCRDT.deserialize(serializedEditorData); + } else { + newEditorCRDT = new EditorCRDT(clientId ? clientId : 0); + } + return newEditorCRDT; + }, [serializedEditorData, clientId]); const editorCRDT = useRef(editorCRDTInstance); + + // editorState도 editorCRDT가 변경될 때마다 업데이트 const [editorState, setEditorState] = useState({ clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, }); + const { sensors, handleDragEnd } = useBlockDragAndDrop({ editorCRDT: editorCRDT.current, editorState, @@ -109,25 +122,27 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr }; const handleBlockClick = (blockId: BlockId, e: React.MouseEvent) => { - const selection = window.getSelection(); - if (!selection) return; + if (editorCRDT) { + const selection = window.getSelection(); + if (!selection) return; - const clickedElement = (e.target as HTMLElement).closest( - '[contenteditable="true"]', - ) as HTMLDivElement; - if (!clickedElement) return; + const clickedElement = (e.target as HTMLElement).closest( + '[contenteditable="true"]', + ) as HTMLDivElement; + if (!clickedElement) return; - editorCRDT.current.currentBlock = - editorCRDT.current.LinkedList.nodeMap[JSON.stringify(blockId)]; - const caretPosition = getAbsoluteCaretPosition(clickedElement); + editorCRDT.current.currentBlock = + editorCRDT.current.LinkedList.nodeMap[JSON.stringify(blockId)]; + const caretPosition = getAbsoluteCaretPosition(clickedElement); - // 계산된 캐럿 위치 저장 - editorCRDT.current.currentBlock.crdt.currentCaret = caretPosition; + // 계산된 캐럿 위치 저장 + editorCRDT.current.currentBlock.crdt.currentCaret = caretPosition; + } }; const handleBlockInput = useCallback( (e: React.FormEvent, block: CRDTBlock) => { - if (!block) return; + if (!block || !editorCRDT) return; if ((e.nativeEvent as InputEvent).isComposing) { return; } @@ -179,7 +194,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr linkedList: editorCRDT.current.LinkedList, }); }, - [sendCharInsertOperation, sendCharDeleteOperation], + [sendCharInsertOperation, sendCharDeleteOperation, editorCRDT, pageId, updatePageData], ); const handleKeyDown = ( @@ -319,48 +334,54 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr }); }; - 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); + 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, + sendCharInsertOperation({ + node: charNode.node, + blockId: block.id, + pageId, + }); }); - }); - block.crdt.currentCaret = caretPosition; - }; + block.crdt.currentCaret = caretPosition; + updatePageData(pageId, editorCRDT.current.serialize()); + }, + [editorCRDT, pageId, sendCharInsertOperation, updatePageData], + ); const subscriptionRef = useRef(false); useEffect(() => { - if (!editorCRDT.current.currentBlock) return; - // TODO: 값이 제대로 들어왔는데 왜 안되는지 확인 필요 + if (!editorCRDT || !editorCRDT.current.currentBlock) return; setCaretPosition({ blockId: editorCRDT.current.currentBlock.id, linkedList: editorCRDT.current.LinkedList, position: editorCRDT.current.currentBlock?.crdt.currentCaret, + pageId, }); // 서윤님 피드백 반영 }, [editorCRDT.current.currentBlock?.id.serialize()]); useEffect(() => { + if (!editorCRDT) return; if (subscriptionRef.current) return; subscriptionRef.current = true; const unsubscribe = subscribeToRemoteOperations({ onRemoteBlockInsert: (operation) => { console.log(operation, "block : 입력 확인합니다이"); - if (!editorCRDT.current) return; + if (operation.pageId !== pageId) return; editorCRDT.current.remoteInsert(operation); setEditorState({ clock: editorCRDT.current.clock, @@ -370,7 +391,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onRemoteBlockDelete: (operation) => { console.log(operation, "block : 삭제 확인합니다이"); - if (!editorCRDT.current) return; + if (operation.pageId !== pageId) return; editorCRDT.current.remoteDelete(operation); setEditorState({ clock: editorCRDT.current.clock, @@ -380,31 +401,35 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onRemoteCharInsert: (operation) => { console.log(operation, "char : 입력 확인합니다이"); - if (!editorCRDT.current) return; + if (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 (targetBlock) { + targetBlock.crdt.remoteInsert(operation); + setEditorState({ + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, + }); + } }, onRemoteCharDelete: (operation) => { console.log(operation, "char : 삭제 확인합니다이"); - if (!editorCRDT.current) return; + if (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 (targetBlock) { + targetBlock.crdt.remoteDelete(operation); + setEditorState({ + clock: editorCRDT.current.clock, + linkedList: editorCRDT.current.LinkedList, + }); + } }, onRemoteBlockUpdate: (operation) => { console.log(operation, "block : 업데이트 확인합니다이"); - if (!editorCRDT.current) return; + if (operation.pageId !== pageId) return; editorCRDT.current.remoteUpdate(operation.node, operation.pageId); setEditorState({ clock: editorCRDT.current.clock, @@ -414,7 +439,7 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onRemoteBlockReorder: (operation) => { console.log(operation, "block : 재정렬 확인합니다이"); - if (!editorCRDT.current) return; + if (operation.pageId !== pageId) return; editorCRDT.current.remoteReorder(operation); setEditorState({ clock: editorCRDT.current.clock, @@ -424,7 +449,8 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr onRemoteCharUpdate: (operation) => { console.log(operation, "char : 업데이트 확인합니다이"); - if (!editorCRDT.current) return; + if (!editorCRDT) return; + if (operation.pageId !== pageId) return; const targetBlock = editorCRDT.current.LinkedList.nodeMap[JSON.stringify(operation.blockId)]; targetBlock.crdt.remoteUpdate(operation); @@ -443,21 +469,25 @@ export const Editor = ({ onTitleChange, pageId, serializedEditorData }: EditorPr subscriptionRef.current = false; unsubscribe?.(); }; - }, []); + }, [editorCRDT, subscribeToRemoteOperations, pageId]); const addNewBlock = () => { + if (!editorCRDT) return; 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: operation.node.id.clock, + setEditorState(() => ({ + clock: editorCRDT.current.clock, linkedList: editorCRDT.current.LinkedList, - }); + })); + updatePageData(pageId, editorCRDT.current.serialize()); }; + // 로딩 상태 체크 + if (!editorCRDT || !editorState) { + return
Loading editor data...
; + } return (
diff --git a/client/src/features/editor/hooks/useMarkdownGrammer.ts b/client/src/features/editor/hooks/useMarkdownGrammer.ts index cca1b4d7..cf23ff73 100644 --- a/client/src/features/editor/hooks/useMarkdownGrammer.ts +++ b/client/src/features/editor/hooks/useMarkdownGrammer.ts @@ -14,7 +14,7 @@ import { setCaretPosition, getAbsoluteCaretPosition } from "@src/utils/caretUtil interface useMarkdownGrammerProps { editorCRDT: EditorCRDT; - editorState: EditorStateProps; + editorState: EditorStateProps; // Add editorRef setEditorState: React.Dispatch< React.SetStateAction<{ clock: number; @@ -298,6 +298,7 @@ export const useMarkdownGrammer = ({ blockId: targetBlock.id, linkedList: editorCRDT.LinkedList, position: Math.min(caretPosition, targetBlock.crdt.read().length), + pageId, }); break; } @@ -319,6 +320,7 @@ export const useMarkdownGrammer = ({ blockId: prevBlock.id, linkedList: editorCRDT.LinkedList, position: prevBlock.crdt.read().length, + pageId, }); } break; @@ -337,6 +339,7 @@ export const useMarkdownGrammer = ({ blockId: nextBlock.id, linkedList: editorCRDT.LinkedList, position: 0, + pageId, }); } break; @@ -358,271 +361,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), - }); - 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, - }); - } - 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, - }); - } - break; - // 블록 내에서 이동하는 경우 - } else { - if (e.key === "ArrowLeft") { - currentBlock.crdt.currentCaret -= 1; - } else { - currentBlock.crdt.currentCaret += 1; - } - } - - break; - } - } - */ diff --git a/client/src/features/page/Page.tsx b/client/src/features/page/Page.tsx index a376a5fd..3434a811 100644 --- a/client/src/features/page/Page.tsx +++ b/client/src/features/page/Page.tsx @@ -1,5 +1,6 @@ import { serializedEditorDataProps } from "@noctaCrdt/Interfaces"; import { motion, AnimatePresence } from "framer-motion"; +import { useEffect, useState } from "react"; import { Editor } from "@features/editor/Editor"; import { Page as PageType } from "@src/types/page"; import { pageContainer, pageHeader, resizeHandles } from "./Page.style"; @@ -11,7 +12,8 @@ interface PageProps extends PageType { handlePageSelect: ({ pageId, isSidebar }: { pageId: string; isSidebar?: boolean }) => void; handlePageClose: (pageId: string) => void; handleTitleChange: (pageId: string, newTitle: string) => void; - serializedEditorData: serializedEditorDataProps; + updatePageData: (pageId: string, newData: serializedEditorDataProps) => void; + serializedEditorData: serializedEditorDataProps | null; } export const Page = ({ @@ -24,12 +26,12 @@ export const Page = ({ handlePageSelect, handlePageClose, handleTitleChange, + updatePageData, serializedEditorData, }: PageProps) => { const { position, size, pageDrag, pageResize, pageMinimize, pageMaximize } = usePage({ x, y }); - - // TODO: workspace에서 pageId, editorCRDT props로 받아와야 함 - // const {} = useSocket(); + const [serializedEditorDatas, setSerializedEditorDatas] = + useState(serializedEditorData); const onTitleChange = (newTitle: string) => { handleTitleChange(id, newTitle); @@ -41,9 +43,18 @@ export const Page = ({ } }; + // serializedEditorData prop이 변경되면 local state도 업데이트 + useEffect(() => { + setSerializedEditorDatas(serializedEditorData); + }, [serializedEditorData, updatePageData]); + + if (!serializedEditorDatas) { + return null; + } return (
{DIRECTIONS.map((direction) => ( { 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, + openPage, + } = usePagesManage(workspace, clientId); const visiblePages = pages.filter((page) => page.isVisible); useEffect(() => { @@ -50,17 +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 6f45be12..a811778a 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"; @@ -47,6 +48,14 @@ export const usePagesManage = (workspace: WorkSpace | null, clientId: number | n }; }, [workspace, pages]); + const updatePageData = useCallback((pageId: string, newData: serializedEditorDataProps) => { + setPages((prevPages) => + prevPages.map((page) => + page.id === pageId ? { ...page, serializedEditorData: newData } : page, + ), + ); + }, []); + const getZIndex = () => { return Math.max(0, ...pages.map((page) => page.zIndex)) + 1; }; @@ -73,27 +82,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 }) => { setPages((prevPages) => - prevPages.map((page) => ({ - ...page, - isActive: page.id === pageId, - ...(page.id === pageId && { - zIndex: getZIndex(), - isVisible: true, - }), - })), + 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 +190,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,7 +206,9 @@ export const usePagesManage = (workspace: WorkSpace | null, clientId: number | n pages, fetchPage, selectPage, + openPage, closePage, + updatePageData, updatePageTitle, initPages, initPagePosition, diff --git a/client/src/stores/useSocketStore.ts b/client/src/stores/useSocketStore.ts index 215d7119..9613b01f 100644 --- a/client/src/stores/useSocketStore.ts +++ b/client/src/stores/useSocketStore.ts @@ -18,7 +18,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; @@ -57,9 +57,8 @@ export const useSocketStore = create((set, get) => ({ clientId: null, workspace: null, - init: () => { + init: (id: string | null) => { const { socket: existingSocket } = get(); - if (existingSocket?.connected) return; if (existingSocket) { existingSocket.disconnect(); @@ -74,6 +73,9 @@ export const useSocketStore = create((set, get) => ({ withCredentials: true, reconnectionAttempts: 5, reconnectionDelay: 1000, + auth: { + userId: id, + }, autoConnect: false, }); 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/client/src/utils/caretUtils.ts b/client/src/utils/caretUtils.ts index 06c0cda1..27e7d422 100644 --- a/client/src/utils/caretUtils.ts +++ b/client/src/utils/caretUtils.ts @@ -4,7 +4,10 @@ import { BlockId } from "@noctaCrdt/NodeId"; interface SetCaretPositionProps { blockId: BlockId; linkedList: BlockLinkedList | TextLinkedList; - position?: number; // 특정 위치로 캐럿을 설정하고 싶을 때 사용 + clientX?: number; + clientY?: number; + position?: number; // Used to set the caret at a specific position + pageId: string; // Add rootElement to scope the query } export const getAbsoluteCaretPosition = (element: HTMLElement): number => { @@ -73,15 +76,19 @@ export const setCaretPosition = ({ blockId, linkedList, position, + pageId, }: SetCaretPositionProps): void => { try { 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 b623a7fb..e3f8276d 100644 --- a/server/src/crdt/crdt.gateway.ts +++ b/server/src/crdt/crdt.gateway.ts @@ -25,7 +25,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; @@ -48,10 +48,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; } @@ -61,26 +61,25 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa */ async handleConnection(client: Socket) { try { + let { userId } = client.handshake.auth; + if (!userId) { + userId = "guest"; + } + client.data.userId = userId; + client.join(userId); + // 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 할당 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) { @@ -115,6 +114,86 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } } + /** + * 페이지 참여 처리 + * 클라이언트가 특정 페이지에 참여할 때 호출됨 + */ + @SubscribeMessage("join/page") + async handleJoinPage( + @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; + const { userId } = client.data; + + // 워크스페이스에서 해당 페이지 찾기 + 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`); + } + + 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) { + 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; + const { userId } = client.data; + 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}`); + } + } + /** * 페이지 삽입 연산 처리 */ @@ -129,24 +208,20 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa `Page create 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), ); - // TODO 클라이언트로부터 받은 page 서버의 인스턴스에 저장한다. - // TODO: 워크스페이스 여러개일 때 처리 해야함 + const { userId } = client.data; + 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); } catch (error) { this.logger.error( `Page Create 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -170,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); @@ -218,30 +293,22 @@ 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상태를 계속 볼수있게 콘솔을 찍어놓고 - // 얘네가 생성될때 - - // 초기값은 client = client 0 clock 0 , server = clinet 0 clock 0 - // 여기서 입력이 발생하면 clinet 가 입력해야 clinet 0 clock 1, server = client0 clock 1 - // 2. 해당 페이지 가져오기 + + const { userId } = client.data; + const workspace = this.workSpaceService.getWorkspace(userId); + 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(data.pageId).emit("update/block", operation); } catch (error) { this.logger.error( `블록 Update 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -265,11 +332,10 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa `Insert 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), ); - // TODO 클라이언트로부터 받은 정보를 서버의 인스턴스에 저장한다. - // 몇번 page의 editorCRDT에 추가가 되냐 + 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`); @@ -280,7 +346,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa node: data.node, pageId: data.pageId, }; - client.broadcast.emit("insert/block", operation); + client.to(data.pageId).emit("insert/block", operation); } catch (error) { this.logger.error( `Block Insert 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -300,16 +366,15 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ): Promise { const clientInfo = this.clientMap.get(client.id); try { + console.log("인서트 char", data.pageId); this.logger.debug( `Insert 연산 수신 - Client ID: ${clientInfo?.clientId}, Data:`, JSON.stringify(data), ); - // blockId 는 수신 받음 - // 원하는 block에 char node 를 삽입해야함 이제. - // !! TODO 블록 찾기 + 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`); @@ -329,7 +394,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa color: data.color ? data.color : "black", backgroundColor: data.backgroundColor ? data.backgroundColor : "transparent", }; - client.broadcast.emit("insert/char", operation); + client.to(data.pageId).emit("insert/char", operation); } catch (error) { this.logger.error( `Char Insert 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -338,6 +403,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa throw new WsException(`Insert 연산 실패: ${error.message}`); } } + /** * 삭제 연산 처리 */ @@ -348,13 +414,14 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ): Promise { const clientInfo = this.clientMap.get(client.id); try { + console.log("딜리트 블록", data.pageId); this.logger.debug( `Delete 연산 수신 - 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`); @@ -365,7 +432,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa clock: data.clock, pageId: data.pageId, }; - client.broadcast.emit("delete/block", operation); + client.to(data.pageId).emit("delete/block", operation); } catch (error) { this.logger.error( `Block Delete 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -385,13 +452,14 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ): Promise { const clientInfo = this.clientMap.get(client.id); try { + console.log("딜리트 캐릭터", data.pageId); this.logger.debug( `Delete 연산 수신 - 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`); @@ -406,8 +474,9 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa targetId: data.targetId, clock: data.clock, blockId: data.blockId, + pageId: data.pageId, }; - client.broadcast.emit("delete/char", operation); + client.to(data.pageId).emit("delete/char", operation); } catch (error) { this.logger.error( `Char Delete 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -428,8 +497,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; + const workspace = this.workSpaceService.getWorkspace(userId); const currentPage = workspace.pageList.find((p) => p.id === data.pageId); if (!currentPage) { @@ -444,7 +513,7 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa afterId: data.afterId, pageId: data.pageId, } as RemoteBlockReorderOperation; - client.broadcast.emit("reorder/block", operation); + client.to(data.pageId).emit("reorder/block", operation); } catch (error) { this.logger.error( `블록 Reorder 연산 처리 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, @@ -465,9 +534,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`); @@ -508,8 +577,8 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa clientId: clientInfo?.clientId, position: data.position, }; - // 커서 정보에 클라이언트 ID 추가하여 브로드캐스트 - client.broadcast.emit("cursor", operation); + const { userId } = client.data; + client.to(userId).emit("cursor", operation); } catch (error) { this.logger.error( `Cursor 업데이트 중 오류 발생 - Client ID: ${clientInfo?.clientId}`, 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], }) diff --git a/server/src/crdt/crdt.service.ts b/server/src/crdt/crdt.service.ts index 3f452869..28701f0b 100644 --- a/server/src/crdt/crdt.service.ts +++ b/server/src/crdt/crdt.service.ts @@ -9,135 +9,165 @@ import { WorkSpaceSerializedProps } from "@noctaCrdt/Interfaces"; @Injectable() export class workSpaceService implements OnModuleInit { - private workspace: CRDTWorkSpace; - private tempPage: CRDTPage; - constructor(@InjectModel(Workspace.name) private workspaceModel: Model) { - this.tempPage = new CRDTPage(); - this.workspace = new CRDTWorkSpace("test", [this.tempPage]); - } - + private workspaces: Map; + constructor(@InjectModel(Workspace.name) private workspaceModel: Model) {} 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 구현 - } +// // 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 문서 가져오기 +// 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 {