diff --git a/@noctaCrdt/Crdt.ts b/@noctaCrdt/Crdt.ts index e69de29b..153bb8fc 100644 --- a/@noctaCrdt/Crdt.ts +++ b/@noctaCrdt/Crdt.ts @@ -0,0 +1,129 @@ +import { LinkedList } from "./LinkedList"; +import { NodeId, Node } from "./Node"; +import { RemoteInsertOperation, RemoteDeleteOperation, SerializedProps } from "./Interfaces"; + +export class CRDT { + clock: number; + client: number; + textLinkedList: LinkedList; + + constructor(client: number) { + this.clock = 0; // 이 CRDT의 논리적 시간 설정 + this.client = client; + this.textLinkedList = new LinkedList(); + } + + /** + * 로컬에서 삽입 연산을 수행하고, 원격에 전파할 연산 객체를 반환합니다. + * @param index 삽입할 인덱스 + * @param value 삽입할 값 + * @returns 원격에 전파할 삽입 연산 객체 + */ + localInsert(index: number, value: string): RemoteInsertOperation { + const id = new NodeId((this.clock += 1), this.client); + const remoteInsertion = this.textLinkedList.insertAtIndex(index, value, id); + return { node: remoteInsertion.node }; + } + + /** + * 로컬에서 삭제 연산을 수행하고, 원격에 전파할 연산 객체를 반환합니다. + * @param index 삭제할 인덱스 + * @returns 원격에 전파할 삭제 연산 객체 + */ + localDelete(index: number): RemoteDeleteOperation { + // 유효한 인덱스인지 확인 + if (index < 0 || index >= this.textLinkedList.spread().length) { + throw new Error(`유효하지 않은 인덱스입니다: ${index}`); + } + + // 삭제할 노드 찾기 + const nodeToDelete = this.textLinkedList.findByIndex(index); + if (!nodeToDelete) { + throw new Error(`삭제할 노드를 찾을 수 없습니다. 인덱스: ${index}`); + } + + // 삭제 연산 객체 생성 + const operation: RemoteDeleteOperation = { + targetId: nodeToDelete.id, + clock: this.clock + 1, + }; + + // 로컬 삭제 수행 + this.textLinkedList.deleteNode(nodeToDelete.id); + + // 클록 업데이트 + this.clock += 1; + + return operation; + } + + /** + * 원격에서 삽입 연산을 수신했을 때 처리합니다. + * @param operation 원격 삽입 연산 객체 + */ + remoteInsert(operation: RemoteInsertOperation): void { + const newNodeId = new NodeId(operation.node.id.clock, operation.node.id.client); + const newNode = new Node(operation.node.value, newNodeId); + newNode.next = operation.node.next; + newNode.prev = operation.node.prev; + this.textLinkedList.insertById(newNode); + // 동기화 논리적 시간 + if (this.clock <= newNode.id.clock) { + this.clock = newNode.id.clock + 1; + } + } + + /** + * 원격에서 삭제 연산을 수신했을때 처리합니다. + * @param operation 원격 삭제 연산 객체 + */ + remoteDelete(operation: RemoteDeleteOperation): void { + const { targetId, clock } = operation; + if (targetId) { + this.textLinkedList.deleteNode(targetId); + } + // 동기화 논리적 시간 + if (this.clock <= clock) { + this.clock = clock + 1; + } + } + + /** + * 현재 텍스트를 문자열로 반환합니다. + * @returns 현재 텍스트 + */ + read(): string { + return this.textLinkedList.stringify(); + } + + /** + * 현재 텍스트를 배열로 반환합니다. + * @returns 현재 텍스트 배열 + */ + spread(): string[] { + return this.textLinkedList.spread(); + } + + /** + * textLinkedList를 반환하는 getter 메서드 + * @returns LinkedList 인스턴스 + */ + public getTextLinkedList(): LinkedList { + return this.textLinkedList; + } + + /** + * CRDT의 상태를 직렬화 가능한 객체로 반환합니다. + * @returns 직렬화 가능한 CRDT 상태 + */ + serialize(): SerializedProps { + return { + clock: this.clock, + client: this.client, + textLinkedList: { + head: this.textLinkedList.head, + nodeMap: this.textLinkedList.nodeMap, + }, + }; + } +} diff --git a/@noctaCrdt/Interfaces.ts b/@noctaCrdt/Interfaces.ts index e69de29b..f3359df6 100644 --- a/@noctaCrdt/Interfaces.ts +++ b/@noctaCrdt/Interfaces.ts @@ -0,0 +1,32 @@ +import { NodeId, Node } from "./Node"; + +export interface InsertOperation { + node: Node; +} + +export interface DeleteOperation { + targetId: NodeId | null; + clock: number; +} +export interface RemoteInsertOperation { + node: Node; +} + +export interface RemoteDeleteOperation { + targetId: NodeId | null; + clock: number; +} + +export interface CursorPosition { + clientId: number; + position: number; +} + +export interface SerializedProps { + clock: number; + client: number; + textLinkedList: { + head: NodeId | null; + nodeMap: { [key: string]: Node }; + }; +} diff --git a/@noctaCrdt/LinkedList.ts b/@noctaCrdt/LinkedList.ts index e69de29b..84b589c8 100644 --- a/@noctaCrdt/LinkedList.ts +++ b/@noctaCrdt/LinkedList.ts @@ -0,0 +1,239 @@ +import { NodeId, Node } from "./Node"; +import { InsertOperation } from "./Interfaces"; + +export class LinkedList { + head: NodeId | null; + nodeMap: { [key: string]: Node }; + + constructor(initialStructure?: LinkedList) { + if (initialStructure) { + this.head = initialStructure.head; + this.nodeMap = { ...initialStructure.nodeMap }; + } else { + this.head = null; + this.nodeMap = {}; + } + } + + // 노드맵에 노드 추가 메소드 + setNode(id: NodeId, node: Node): void { + this.nodeMap[JSON.stringify(id)] = node; + } + + // 노드맵에서 노드 조회 메서드 + getNode(id: NodeId | null): Node | null { + if (!id) return null; + return this.nodeMap[JSON.stringify(id)] || null; + } + + // 링크드 리스트에서 노드를 제거하고 nodeMap에서 삭제 + deleteNode(id: NodeId): void { + const nodeToDelete = this.getNode(id); + if (!nodeToDelete) return; + + // 삭제할 노드가 헤드인 경우 + if (this.head && this.head.equals(id)) { + this.head = nodeToDelete.next; + if (nodeToDelete.next) { + const nextNode = this.getNode(nodeToDelete.next); + if (nextNode) { + nextNode.prev = null; + } + } + } else { + // 삭제할 노드의 이전 노드를 찾아 연결을 끊는다. + if (nodeToDelete.prev) { + const prevNode = this.getNode(nodeToDelete.prev); + if (prevNode) { + prevNode.next = nodeToDelete.next; + if (nodeToDelete.next) { + const nextNode = this.getNode(nodeToDelete.next); + if (nextNode) { + nextNode.prev = nodeToDelete.prev; + } + } + } + } + } + + // nodeMap에서 노드 삭제 + delete this.nodeMap[JSON.stringify(id)]; + } + + /** + * 링크드 리스트 안에 특정 인덱스에 해당하는 노드를 찾습니다. + * @param index 찾을 인덱스 (0-부터 출발한다.) + * @returns 해당 인덱스의 노드 + */ + findByIndex(index: number): Node { + if (index < 0) { + throw new Error(`링크드 리스트에서 특정 인덱스${index}가 음수가 입력되었습니다.`); + } + + let currentNodeId = this.head; + let currentIndex = 0; + + while (currentNodeId !== null && currentIndex < index) { + const currentNode = this.getNode(currentNodeId); + if (!currentNode) { + throw new Error( + `링크드 리스트에서 특정 인덱스에 해당하는 노드를 찾다가 에러가 발생했습니다. ${currentIndex}`, + ); + } + currentNodeId = currentNode.next; + currentIndex += 1; + } + + // 유효성 검사 + if (currentNodeId === null) { + throw new Error(`링크드 리스트에서 ${index}를 조회했지만 링크드 리스트가 비어있습니다. `); + } + const node = this.getNode(currentNodeId); + if (!node) { + throw new Error(`링크드 리스트에서 인덱스 ${index}에서 노드를 가져오지 못했습니다. `); + } + + return node; + } + + /** + * 인덱스를 기반으로 노드를 삽입합니다. + * 글자를 작성할때 특정 인덱스에 삽입해야 하기 때문. + * @param index 삽입할 인덱스 (0-based) + * @param value 삽입할 값 + * @param id 삽입할 노드의 식별자 + * @returns 삽입된 노드 + */ + insertAtIndex(index: number, value: string, id: NodeId): InsertOperation { + try { + const node = new Node(value, id); + this.setNode(id, node); + + // 헤드에 삽입하는 경우 + if (!this.head || index === -1) { + node.next = this.head; + node.prev = null; + if (this.head) { + const oldHead = this.getNode(this.head); + if (oldHead) { + oldHead.prev = id; + } + } + + this.head = id; + return { node }; + } + + // 삽입할 위치의 이전 노드 찾기 + const prevNode = this.findByIndex(index - 1); + + node.next = prevNode.next; + prevNode.next = id; + node.prev = prevNode.id; + + // 노드의 다음께 있으면 node를 얻고 다음 노드의 prev가 새로 추가된 노드로 업데이트 + if (node.next) { + const nextNode = this.getNode(node.next); + if (nextNode) { + nextNode.prev = id; + } + } + + return { node }; + } catch (e) { + throw new Error(`링크드 리스트 내에서 insertAtIndex 실패\n${e}`); + } + } + + /** + * 원격 삽입 연산을 처리합니다. + * 원격 연산이 왔을때는 이미 node정보가 완성된 상태로 수신하여 큰 연산이 필요 없다. + * @param node 삽입할 노드 객체 + * @returns 수정된 인덱스 (선택사항) + */ + insertById(node: Node): void { + // 이미 존재하는 노드라면 무시 + if (this.getNode(node.id)) { + return; + } + + // 노드의 prev가 null이면 헤드에 삽입 + if (!node.prev) { + node.next = this.head; + node.prev = null; + + if (this.head) { + const oldHead = this.getNode(this.head); + if (oldHead) { + oldHead.prev = node.id; + } + } + + this.head = node.id; + this.setNode(node.id, node); + return; + } + + // 삽입할 위치의 이전 노드 찾기 + const prevNode = this.getNode(node.prev); + if (!prevNode) { + throw new Error( + `원격 삽입 시, 이전 노드를 찾을 수 없습니다. prevId: ${JSON.stringify(node.prev)}`, + ); + } + + // 새 노드의 다음을 이전 노드의 다음으로 설정 + node.next = prevNode.next; + node.prev = prevNode.id; + + // 이전 노드의 다음을 새 노드로 설정 + prevNode.next = node.id; + + // 새 노드의 다음 노드가 있다면, 그 노드의 prev를 새 노드로 업데이트 + if (node.next) { + const nextNode = this.getNode(node.next); + if (nextNode) { + nextNode.prev = node.id; + } + } + + // 새 노드를 nodeMap에 추가 + this.setNode(node.id, node); + } + + /** + * 현재 리스트를 문자열로 변환합니다. + * @returns 링크드 리스트를 순회하여 얻은 문자열 + */ + stringify(): string { + let currentNodeId = this.head; + let result = ""; + + while (currentNodeId !== null) { + const currentNode = this.getNode(currentNodeId); + if (!currentNode) break; + result += currentNode.value; + currentNodeId = currentNode.next; + } + + return result; + } + + /** + * 현재 리스트를 배열로 변환합니다. + * @returns 배열로 변환된 리스트 + */ + spread(): string[] { + let currentNodeId = this.head; + const result: string[] = []; + + while (currentNodeId !== null) { + const currentNode = this.getNode(currentNodeId); + if (!currentNode) break; + result.push(currentNode.value); + currentNodeId = currentNode.next; + } + + return result; + } +} diff --git a/@noctaCrdt/Node.ts b/@noctaCrdt/Node.ts index e69de29b..2b874457 100644 --- a/@noctaCrdt/Node.ts +++ b/@noctaCrdt/Node.ts @@ -0,0 +1,43 @@ +export class NodeId { + clock: number; + client: number; + + constructor(clock: number, client: number) { + this.clock = clock; + this.client = client; + } + + equals(other: NodeId): boolean { + return this.clock === other.clock && this.client === other.client; + } +} + +export class Node { + id: NodeId; + value: string; + next: NodeId | null; + prev: NodeId | null; + + constructor(value: string, id: NodeId) { + this.id = id; + this.value = value; + this.next = null; + this.prev = null; + } + + /** + * 두 노드의 순서를 비교하여, 이 노드가 다른 노드보다 먼저 와야 하는지 여부를 반환합니다. + * @param node 비교할 노드 + * @returns 순서 결정 결과 + */ + precedes(node: Node): boolean { + // prev가 다르면 비교 불가 + if (!this.prev || !node.prev) return false; + if (!this.prev.equals(node.prev)) return false; + + if (this.id.clock < node.id.clock) return true; + if (this.id.clock === node.id.clock && this.id.client < node.id.client) return true; + + return false; + } +} diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index 80430873..e2d2af69 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -1,10 +1,10 @@ import { useRef, useState, useCallback, useEffect } from "react"; -import { EditorState } from "../../types/markdown"; import { Block } from "@components/block/Block"; import { useCaretManager } from "@hooks/useCaretManager"; import { useKeyboardHandlers } from "@hooks/useMarkdownGrammer"; import { LinkedListBlock } from "@utils/linkedLIstBlock"; import { checkMarkdownPattern } from "@utils/markdownPatterns"; +import { EditorState } from "../../types/markdown"; import { editorContainer, editorTitleContainer,