diff --git a/@noctaCrdt/Crdt.ts b/@noctaCrdt/Crdt.ts index db62dd79..fb99042e 100644 --- a/@noctaCrdt/Crdt.ts +++ b/@noctaCrdt/Crdt.ts @@ -114,7 +114,7 @@ export class EditorCRDT extends CRDT { updatedBlock.indent = block.indent; updatedBlock.style = block.style; updatedBlock.type = block.type; - // this.LinkedList.nodeMap[JSON.stringify(block.id)] = block; + updatedBlock.listIndex = block.listIndex || undefined; return { node: updatedBlock, pageId }; } @@ -125,7 +125,7 @@ export class EditorCRDT extends CRDT { updatedBlock.indent = block.indent; updatedBlock.style = block.style; updatedBlock.type = block.type; - // this.LinkedList.nodeMap[JSON.stringify(block.id)] = block; + updatedBlock.listIndex = block.listIndex || undefined; return { node: updatedBlock, pageId }; } @@ -136,14 +136,10 @@ export class EditorCRDT extends CRDT { newNode.next = operation.node.next; newNode.prev = operation.node.prev; newNode.indent = operation.node.indent; + newNode.listIndex = operation.node.listIndex || undefined; this.LinkedList.insertById(newNode); this.clock = Math.max(this.clock, operation.node.id.clock) + 1; - /* - if (this.clock <= newNode.id.clock) { - this.clock = newNode.id.clock + 1; - } - */ } remoteDelete(operation: RemoteBlockDeleteOperation): void { @@ -153,11 +149,6 @@ export class EditorCRDT extends CRDT { this.LinkedList.deleteNode(targetNodeId); } this.clock = Math.max(this.clock, clock) + 1; - /* - if (this.clock <= clock) { - this.clock = clock + 1; - } - */ } localReorder(params: { diff --git a/@noctaCrdt/LinkedList.ts b/@noctaCrdt/LinkedList.ts index f521afe8..de84c19a 100644 --- a/@noctaCrdt/LinkedList.ts +++ b/@noctaCrdt/LinkedList.ts @@ -55,65 +55,9 @@ export abstract class LinkedList> { delete this.nodeMap[JSON.stringify(id)]; } - reorderNodes({ targetId, beforeId, afterId }: ReorderNodesProps): void { - const targetNode = this.getNode(targetId); - if (!targetNode) return; - - // 1. 기존 연결 해제 - if (targetNode.prev) { - const prevNode = this.getNode(targetNode.prev); - if (prevNode) { - prevNode.next = targetNode.next; - } - } else { - this.head = targetNode.next; - } - - if (targetNode.next) { - const nextNode = this.getNode(targetNode.next); - if (nextNode) { - nextNode.prev = targetNode.prev; - } - } - - // 2. 새로운 위치에 연결 - if (!beforeId) { - // 맨 앞으로 이동 - const oldHead = this.head; - this.head = targetId; - targetNode.prev = null; - targetNode.next = oldHead; - - if (oldHead) { - const headNode = this.getNode(oldHead); - if (headNode) { - headNode.prev = targetId; - } - } - } else if (!afterId) { - // 맨 끝으로 이동 - const beforeNode = this.getNode(beforeId); - if (beforeNode) { - beforeNode.next = targetId; - targetNode.prev = beforeId; - targetNode.next = null; - } - } else { - // 중간으로 이동 - const beforeNode = this.getNode(beforeId); - const afterNode = this.getNode(afterId); - - if (beforeNode && afterNode) { - targetNode.prev = beforeId; - targetNode.next = afterId; - beforeNode.next = targetId; - afterNode.prev = targetId; - } - } + updateAllOrderedListIndices() {} - // 노드맵 갱신 - this.setNode(targetId, targetNode); - } + reorderNodes({ targetId, beforeId, afterId }: ReorderNodesProps): void {} findByIndex(index: number): T { if (index < 0) { @@ -299,6 +243,116 @@ export abstract class LinkedList> { } export class BlockLinkedList extends LinkedList { + updateAllOrderedListIndices() { + let currentNode = this.getNode(this.head); + let currentIndex = 1; + + while (currentNode) { + if (currentNode.type === "ol") { + const prevNode = currentNode.prev ? this.getNode(currentNode.prev) : null; + + if (!prevNode || prevNode.type !== "ol") { + // 이전 노드가 없거나 ol이 아닌 경우 1부터 시작 + currentIndex = 1; + } else if (prevNode.indent !== currentNode.indent) { + // indent가 다른 경우 + if (currentNode.indent > prevNode.indent) { + // indent가 증가한 경우 1부터 시작 + currentIndex = 1; + } else { + // indent가 감소한 경우 같은 indent를 가진 이전 ol의 번호 다음부터 시작 + let prevSameIndentNode = prevNode; + while ( + prevSameIndentNode && + (prevSameIndentNode.indent !== currentNode.indent || prevSameIndentNode.type !== "ol") + ) { + if (prevSameIndentNode.prev) { + prevSameIndentNode = this.getNode(prevSameIndentNode.prev)!; + } else { + break; + } + } + + if (prevSameIndentNode && prevSameIndentNode.type === "ol") { + currentIndex = prevSameIndentNode.listIndex! + 1; + } else { + currentIndex = 1; + } + } + } else { + // 같은 indent의 연속된 ol인 경우 번호 증가 + currentIndex = prevNode.listIndex!; + currentIndex += 1; + } + + currentNode.listIndex = currentIndex; + } + + currentNode = currentNode.next ? this.getNode(currentNode.next) : null; + } + } + + reorderNodes({ targetId, beforeId, afterId }: ReorderNodesProps) { + const targetNode = this.getNode(targetId); + if (!targetNode) return; + + // 1. 현재 위치에서 노드 제거 + if (targetNode.prev) { + const prevNode = this.getNode(targetNode.prev); + if (prevNode) prevNode.next = targetNode.next; + } + + if (targetNode.next) { + const nextNode = this.getNode(targetNode.next); + if (nextNode) nextNode.prev = targetNode.prev; + } + + if (this.head === targetId) { + this.head = targetNode.next; + } + + // 2. 새로운 위치에 노드 삽입 + if (!beforeId) { + // 맨 앞으로 이동 + const oldHead = this.head; + this.head = targetId; + targetNode.prev = null; + targetNode.next = oldHead; + + if (oldHead) { + const headNode = this.getNode(oldHead); + if (headNode) headNode.prev = targetId; + } + } else if (!afterId) { + // 맨 끝으로 이동 + const beforeNode = this.getNode(beforeId); + if (beforeNode) { + beforeNode.next = targetId; + targetNode.prev = beforeId; + targetNode.next = null; + } + } else { + // 중간으로 이동 + const beforeNode = this.getNode(beforeId); + const afterNode = this.getNode(afterId); + + if (beforeNode && afterNode) { + targetNode.prev = beforeId; + targetNode.next = afterId; + beforeNode.next = targetId; + afterNode.prev = targetId; + } + } + + // 노드맵 갱신 + this.setNode(targetId, targetNode); + + // ordered list가 포함된 경우 전체 인덱스 재계산 + if (targetNode.type === "ol") { + this.updateAllOrderedListIndices(); + } + } + deserializeNodeId(data: any): BlockId { return BlockId.deserialize(data); } diff --git a/@noctaCrdt/Node.ts b/@noctaCrdt/Node.ts index f0bb2148..d7220560 100644 --- a/@noctaCrdt/Node.ts +++ b/@noctaCrdt/Node.ts @@ -50,6 +50,7 @@ export class Block extends Node { style: string[]; icon: string; crdt: BlockCRDT; + listIndex?: number; constructor(value: string, id: BlockId) { super(value, id); @@ -70,6 +71,7 @@ export class Block extends Node { style: this.style, icon: this.icon, crdt: this.crdt.serialize(), + listIndex: this.listIndex ? this.listIndex : null, }; } @@ -84,6 +86,7 @@ export class Block extends Node { block.style = data.style; block.icon = data.icon; block.crdt = BlockCRDT.deserialize(data.crdt); + block.listIndex = data.listIndex ? data.listIndex : null; return block; } } diff --git a/client/src/features/editor/components/IconBlock/IconBlock.tsx b/client/src/features/editor/components/IconBlock/IconBlock.tsx index f5670761..5c369560 100644 --- a/client/src/features/editor/components/IconBlock/IconBlock.tsx +++ b/client/src/features/editor/components/IconBlock/IconBlock.tsx @@ -3,7 +3,7 @@ import { iconContainerStyle, iconStyle } from "./IconBlock.style"; interface IconBlockProps { type: ElementType; - index?: number; + index: number | undefined; } export const IconBlock = ({ type, index = 1 }: IconBlockProps) => { diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index 5af74e9d..d3e95fe8 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -18,9 +18,9 @@ import { setInnerHTML, getTextOffset } from "../../utils/domSyncUtils"; import { IconBlock } from "../IconBlock/IconBlock"; import { MenuBlock } from "../MenuBlock/MenuBlock"; import { TextOptionModal } from "../TextOptionModal/TextOptionModal"; +import { TypeOptionModal } from "../TypeOptionModal/TypeOptionModal"; import { blockAnimation } from "./Block.animation"; import { textContainerStyle, blockContainerStyle, contentWrapperStyle } from "./Block.style"; -import { TypeOptionModal } from "../TypeOptionModal/TypeOptionModal"; interface BlockProps { id: string; @@ -246,7 +246,7 @@ export const Block: React.FC = memo( onCopySelect={handleCopySelect} onDeleteSelect={handleDeleteSelect} /> - +
onKeyDown(e, blockRef.current, block)} diff --git a/client/src/features/editor/hooks/useBlockOption.ts b/client/src/features/editor/hooks/useBlockOption.ts index 48f92c73..f1c7ef61 100644 --- a/client/src/features/editor/hooks/useBlockOption.ts +++ b/client/src/features/editor/hooks/useBlockOption.ts @@ -133,6 +133,7 @@ export const useBlockOptionSelect = ({ }); editorCRDT.currentBlock = copiedParent; + editorCRDT.LinkedList.updateAllOrderedListIndices(); setEditorState({ clock: editorCRDT.clock, linkedList: editorCRDT.LinkedList, @@ -178,6 +179,9 @@ export const useBlockOptionSelect = ({ editorCRDT.currentBlock = nextBlock || prevBlock || null; } + // ol 노드의 index를 다시 설정 + editorCRDT.LinkedList.updateAllOrderedListIndices(); + setEditorState({ clock: editorCRDT.clock, linkedList: editorCRDT.LinkedList, diff --git a/client/src/features/editor/hooks/useMarkdownGrammer.ts b/client/src/features/editor/hooks/useMarkdownGrammer.ts index 2c55d250..120929d2 100644 --- a/client/src/features/editor/hooks/useMarkdownGrammer.ts +++ b/client/src/features/editor/hooks/useMarkdownGrammer.ts @@ -7,11 +7,11 @@ import { RemoteCharDeleteOperation, } from "@noctaCrdt/Interfaces"; import { BlockLinkedList } from "@noctaCrdt/LinkedList"; +import { Block } from "@noctaCrdt/Node"; import { useCallback } from "react"; import { EditorStateProps } from "@features/editor/Editor"; import { checkMarkdownPattern } from "@src/features/editor/utils/markdownPatterns"; import { setCaretPosition, getAbsoluteCaretPosition } from "@src/utils/caretUtils"; -import { Block } from "node_modules/@noctaCrdt/Node"; interface useMarkdownGrammerProps { editorCRDT: EditorCRDT; @@ -83,10 +83,14 @@ export const useMarkdownGrammer = ({ const currentCharNodes = currentBlock.crdt.spread(); if (!currentContent && currentBlock.type !== "p") { + const wasOrderedList = currentBlock.type === "ol"; currentBlock.type = "p"; sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); editorCRDT.currentBlock = currentBlock; editorCRDT.currentBlock.crdt.currentCaret = 0; + if (wasOrderedList) { + editorCRDT.LinkedList.updateAllOrderedListIndices(); + } updateEditorState(); break; } @@ -143,6 +147,12 @@ export const useMarkdownGrammer = ({ editorCRDT.currentBlock = operation.node; editorCRDT.currentBlock.crdt.currentCaret = 0; + + // 모든 ordered list 인덱스 재계산 + if (currentBlock.type === "ol") { + editorCRDT.LinkedList.updateAllOrderedListIndices(); + } + updateEditorState(); break; } @@ -153,18 +163,54 @@ export const useMarkdownGrammer = ({ if (currentContent === "") { e.preventDefault(); if (currentBlock.indent > 0) { + const wasOrderedList = currentBlock.type === "ol"; currentBlock.indent -= 1; sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); editorCRDT.currentBlock = currentBlock; + if (wasOrderedList) { + editorCRDT.LinkedList.updateAllOrderedListIndices(); + } updateEditorState(); break; } + // 현재 블록이 기본 블록이면서 앞뒤가 ordered list인 경우 확인 + if (currentBlock.type === "p") { + const prevBlock = currentBlock.prev + ? editorCRDT.LinkedList.getNode(currentBlock.prev) + : null; + const nextBlock = currentBlock.next + ? editorCRDT.LinkedList.getNode(currentBlock.next) + : null; + + if (prevBlock?.type === "ol" && nextBlock?.type === "ol") { + // 현재 블록 삭제 + sendBlockDeleteOperation(editorCRDT.localDelete(currentIndex, undefined, pageId)); + + // 리스트 인덱스 재계산 + editorCRDT.LinkedList.updateAllOrderedListIndices(); + + // 이전 블록으로 캐럿 이동 + editorCRDT.currentBlock = prevBlock; + editorCRDT.currentBlock.crdt.currentCaret = prevBlock.crdt.read().length; + + updateEditorState(); + break; + } + } + if (currentBlock.type !== "p") { + const wasOrderedList = currentBlock.type === "ol"; // 마지막 블록이면 기본 블록으로 변경 currentBlock.type = "p"; sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); editorCRDT.currentBlock = currentBlock; + + // ol이었던 경우에만 ordered list 인덱스 재계산 + if (wasOrderedList) { + editorCRDT.LinkedList.updateAllOrderedListIndices(); + } + updateEditorState(); break; } @@ -210,16 +256,23 @@ export const useMarkdownGrammer = ({ break; } if (currentBlock.type !== "p") { + const wasOrderedList = currentBlock.type === "ol"; currentBlock.type = "p"; sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); editorCRDT.currentBlock = currentBlock; + if (wasOrderedList) { + editorCRDT.LinkedList.updateAllOrderedListIndices(); + } updateEditorState(); break; } // FIX: 서윤님 피드백 반영 const prevBlock = currentIndex > 0 ? editorCRDT.LinkedList.findByIndex(currentIndex - 1) : null; - + const nextBlock = + currentIndex < editorCRDT.LinkedList.spread().length - 1 + ? editorCRDT.LinkedList.findByIndex(currentIndex + 1) + : null; if (prevBlock) { let targetIndex = currentIndex - 1; let targetBlock = findBlock(targetIndex); @@ -258,8 +311,10 @@ export const useMarkdownGrammer = ({ editorCRDT.currentBlock = prevBlock; editorCRDT.currentBlock.crdt.currentCaret = prevBlockEndCaret; sendBlockDeleteOperation(editorCRDT.localDelete(currentIndex, undefined, pageId)); - updateEditorState(); + if (prevBlock.type === "ol" && nextBlock?.type === "ol") { + editorCRDT.LinkedList.updateAllOrderedListIndices(); + } e.preventDefault(); } } @@ -274,18 +329,26 @@ export const useMarkdownGrammer = ({ if (e.shiftKey) { // shift + tab: 들여쓰기 감소 if (currentBlock.indent > 0) { + const isOrderedList = currentBlock.type === "ol"; currentBlock.indent -= 1; sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); editorCRDT.currentBlock = currentBlock; + if (isOrderedList) { + editorCRDT.LinkedList.updateAllOrderedListIndices(); + } updateEditorState(); } } else { // tab: 들여쓰기 증가 const maxIndent = 3; if (currentBlock.indent < maxIndent) { + const isOrderedList = currentBlock.type === "ol"; currentBlock.indent += 1; sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); editorCRDT.currentBlock = currentBlock; + if (isOrderedList) { + editorCRDT.LinkedList.updateAllOrderedListIndices(); + } updateEditorState(); } } @@ -309,6 +372,9 @@ export const useMarkdownGrammer = ({ sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); currentBlock.crdt.currentCaret = 0; editorCRDT.currentBlock = currentBlock; + if (markdownElement.type === "ol") { + editorCRDT.LinkedList.updateAllOrderedListIndices(); + } updateEditorState(); }