From 60d760fbf64f52b165e62592b79090764d18d8c6 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sun, 4 May 2025 02:08:30 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=ED=95=B8=EB=93=A4=EB=9F=AC=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=EB=B3=84=20=EC=9C=A0=ED=8B=B8=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/markdowngrammer/utils/index.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 client/src/features/editor/hooks/markdowngrammer/utils/index.ts diff --git a/client/src/features/editor/hooks/markdowngrammer/utils/index.ts b/client/src/features/editor/hooks/markdowngrammer/utils/index.ts new file mode 100644 index 0000000..4e4e22f --- /dev/null +++ b/client/src/features/editor/hooks/markdowngrammer/utils/index.ts @@ -0,0 +1,80 @@ +import { EditorCRDT } from "@noctaCrdt/Crdt"; +import { BlockLinkedList } from "@noctaCrdt/LinkedList"; +import { Block } from "@noctaCrdt/Node"; +import { RemoteBlockUpdateOperation } from "@noctaCrdt/types/Interfaces"; + +type SetEditorState = React.Dispatch< + React.SetStateAction<{ + clock: number; + linkedList: BlockLinkedList; + }> +>; + +export const createNewBlock = (editorCRDT: EditorCRDT, index: number) => { + const operation = editorCRDT.localInsert(index, ""); + operation.node.type = "p"; + return operation; +}; + +export const updateEditorState = (editorCRDT: EditorCRDT, setEditorState: SetEditorState) => { + setEditorState({ + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, + }); +}; + +export const findBlockByIndex = (editorCRDT: EditorCRDT, index: number) => { + if (index < 0) return null; + if (index >= editorCRDT.LinkedList.spread().length) return null; + + return editorCRDT.LinkedList.findByIndex(index); +}; + +export const decreaseIndent = ( + editorCRDT: EditorCRDT, + block: Block, + pageId: string, + setEditorState: SetEditorState, + sendBlockUpdateOperation: (operation: RemoteBlockUpdateOperation) => void, +) => { + if (block.indent === 0) return; + + const currentIndex = editorCRDT.LinkedList.spread().findIndex((block) => + block.id.equals(block.id), + ); + + // 현재 블록의 indent 감소 + const wasOrderedList = block.type === "ol"; + const originalIndent = block.indent; + const newIndent = originalIndent - 1; + block.indent = newIndent; + sendBlockUpdateOperation(editorCRDT.localUpdate(block, pageId)); + + // 자식 블록들 찾기 및 업데이트 + const blocks = editorCRDT.LinkedList.spread(); + let i = currentIndex + 1; + + // 현재 블록의 원래 indent보다 큰 블록들만 처리 (자식 블록들만) + while (i < blocks.length && blocks[i].indent > originalIndent) { + const childBlock = blocks[i]; + + // 자식 블록의 indent도 1 감소 + childBlock.indent = Math.max(0, childBlock.indent - 1); + sendBlockUpdateOperation(editorCRDT.localUpdate(childBlock, pageId)); + + i += 1; + } + + // ordered list인 경우 인덱스 업데이트 + if (wasOrderedList) { + editorCRDT.LinkedList.updateAllOrderedListIndices(); + } + + editorCRDT.currentBlock = block; + updateEditorState(editorCRDT, setEditorState); +}; + +export const isEditableBlock = (block: Block | null): boolean => { + if (!block) return false; + return block.type !== "hr"; +}; From c254462f0a674c7d0114c76368d1b63f961ae856 Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sun, 4 May 2025 02:09:02 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=ED=95=B8=EB=93=A4=EB=9F=AC=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=A0=84=EB=8B=AC?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=BB=A8=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=83=80=EC=9E=85=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/hooks/markdowngrammer/types.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 client/src/features/editor/hooks/markdowngrammer/types.ts diff --git a/client/src/features/editor/hooks/markdowngrammer/types.ts b/client/src/features/editor/hooks/markdowngrammer/types.ts new file mode 100644 index 0000000..152c76b --- /dev/null +++ b/client/src/features/editor/hooks/markdowngrammer/types.ts @@ -0,0 +1,29 @@ +import { EditorCRDT } from "@noctaCrdt/Crdt"; +import { BlockLinkedList } from "@noctaCrdt/LinkedList"; + +import { + RemoteBlockInsertOperation, + RemoteBlockDeleteOperation, + RemoteBlockUpdateOperation, + RemoteCharInsertOperation, + RemoteCharDeleteOperation, +} from "@noctaCrdt/types/Interfaces"; + +export type SetEditorState = React.Dispatch< + React.SetStateAction<{ + clock: number; + linkedList: BlockLinkedList; + }> +>; + +export interface KeyHandlerContext { + editorCRDT: EditorCRDT; + setEditorState: SetEditorState; + pageId: string; + clientId: number; + sendBlockInsertOperation: (op: RemoteBlockInsertOperation) => void; + sendBlockDeleteOperation: (op: RemoteBlockDeleteOperation) => void; + sendCharDeleteOperation: (op: RemoteCharDeleteOperation) => void; + sendCharInsertOperation: (op: RemoteCharInsertOperation) => void; + sendBlockUpdateOperation: (op: RemoteBlockUpdateOperation) => void; +} From 6d911745f71178259e7d0a1ebb1c501cc703579f Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sun, 4 May 2025 02:09:44 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=ED=82=A4=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=EB=B3=84=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20-=20=EA=B8=B0=EC=A1=B4=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EC=99=80=20=EB=8F=99=EC=9D=BC=ED=95=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=9D=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/markdowngrammer/handlers/arrow.ts | 135 ++++++++++++++ .../markdowngrammer/handlers/backspace.ts | 171 ++++++++++++++++++ .../hooks/markdowngrammer/handlers/delete.ts | 27 +++ .../hooks/markdowngrammer/handlers/enter.ts | 103 +++++++++++ .../hooks/markdowngrammer/handlers/homeend.ts | 20 ++ .../hooks/markdowngrammer/handlers/page.ts | 54 ++++++ .../hooks/markdowngrammer/handlers/space.ts | 41 +++++ .../hooks/markdowngrammer/handlers/tab.ts | 42 +++++ 8 files changed, 593 insertions(+) create mode 100644 client/src/features/editor/hooks/markdowngrammer/handlers/arrow.ts create mode 100644 client/src/features/editor/hooks/markdowngrammer/handlers/backspace.ts create mode 100644 client/src/features/editor/hooks/markdowngrammer/handlers/delete.ts create mode 100644 client/src/features/editor/hooks/markdowngrammer/handlers/enter.ts create mode 100644 client/src/features/editor/hooks/markdowngrammer/handlers/homeend.ts create mode 100644 client/src/features/editor/hooks/markdowngrammer/handlers/page.ts create mode 100644 client/src/features/editor/hooks/markdowngrammer/handlers/space.ts create mode 100644 client/src/features/editor/hooks/markdowngrammer/handlers/tab.ts diff --git a/client/src/features/editor/hooks/markdowngrammer/handlers/arrow.ts b/client/src/features/editor/hooks/markdowngrammer/handlers/arrow.ts new file mode 100644 index 0000000..9058744 --- /dev/null +++ b/client/src/features/editor/hooks/markdowngrammer/handlers/arrow.ts @@ -0,0 +1,135 @@ +import { getAbsoluteCaretPosition, setCaretPosition } from "@src/utils/caretUtils"; +import { KeyHandlerContext } from "../types"; +import { findBlockByIndex, isEditableBlock } from "../utils"; + +export const handleArrowKey = (e: React.KeyboardEvent, ctx: KeyHandlerContext) => { + const { editorCRDT, pageId } = ctx; + + const { currentBlock } = editorCRDT; + if (!currentBlock || e.nativeEvent.isComposing) return; + + const currentIndex = editorCRDT.LinkedList.spread().findIndex((block) => + block.id.equals(currentBlock.id), + ); + + const caretPosition = getAbsoluteCaretPosition(e.currentTarget); + const textLength = currentBlock.crdt.read().length; + + const moveToBlock = (targetBlockIndex: number, caretPos: number) => { + const targetBlock = findBlockByIndex(editorCRDT, targetBlockIndex); + if (targetBlock && isEditableBlock(targetBlock)) { + targetBlock.crdt.currentCaret = Math.min(caretPos, targetBlock.crdt.read().length); + editorCRDT.currentBlock = targetBlock; + setCaretPosition({ + blockId: targetBlock.id, + position: targetBlock.crdt.currentCaret, + pageId, + }); + } + }; + + switch (e.key) { + case "ArrowUp": { + const hasPrev = currentIndex > 0; + if (!hasPrev) { + e.preventDefault(); + return; + } + + let targetIndex = currentIndex - 1; + let targetBlock = findBlockByIndex(editorCRDT, targetIndex); + + while (targetBlock && targetBlock.type === "hr") { + targetIndex -= 1; + targetBlock = findBlockByIndex(editorCRDT, targetIndex); + } + + if (!targetBlock || targetBlock.type === "hr") return; + + e.preventDefault(); + moveToBlock(targetIndex, caretPosition); + break; + } + + case "ArrowDown": { + const hasNext = currentIndex < editorCRDT.LinkedList.spread().length - 1; + if (!hasNext) { + e.preventDefault(); + return; + } + + let targetIndex = currentIndex + 1; + let targetBlock = findBlockByIndex(editorCRDT, targetIndex); + + while (targetBlock && targetBlock.type === "hr") { + targetIndex += 1; + targetBlock = findBlockByIndex(editorCRDT, targetIndex); + } + + if (!targetBlock || targetBlock.type === "hr") return; + + e.preventDefault(); + moveToBlock(targetIndex, caretPosition); + break; + } + + case "ArrowLeft": { + if (caretPosition === 0 && currentIndex > 0) { + e.preventDefault(); + + let targetIndex = currentIndex - 1; + let targetBlock = findBlockByIndex(editorCRDT, targetIndex); + + while (targetBlock && targetBlock.type === "hr") { + targetIndex -= 1; + targetBlock = findBlockByIndex(editorCRDT, targetIndex); + } + + if (targetBlock && targetBlock.type !== "hr") { + targetBlock.crdt.currentCaret = targetBlock.crdt.read().length; + editorCRDT.currentBlock = targetBlock; + setCaretPosition({ + blockId: targetBlock.id, + position: targetBlock.crdt.read().length, + pageId, + }); + } + break; + } else { + currentBlock.crdt.currentCaret = Math.max(0, caretPosition - 1); + } + break; + } + + case "ArrowRight": { + if ( + caretPosition === textLength && + currentIndex < editorCRDT.LinkedList.spread().length - 1 + ) { + e.preventDefault(); + + let targetIndex = currentIndex + 1; + let targetBlock = findBlockByIndex(editorCRDT, targetIndex); + + while (targetBlock && targetBlock.type === "hr") { + targetIndex += 1; + targetBlock = findBlockByIndex(editorCRDT, targetIndex); + } + + if (targetBlock && targetBlock.type !== "hr") { + targetBlock.crdt.currentCaret = 0; + editorCRDT.currentBlock = targetBlock; + setCaretPosition({ + blockId: targetBlock.id, + position: 0, + pageId, + }); + } + break; + } else { + currentBlock.crdt.currentCaret = Math.min(textLength, caretPosition + 1); + } + break; + } + } +}; diff --git a/client/src/features/editor/hooks/markdowngrammer/handlers/backspace.ts b/client/src/features/editor/hooks/markdowngrammer/handlers/backspace.ts new file mode 100644 index 0000000..0af884a --- /dev/null +++ b/client/src/features/editor/hooks/markdowngrammer/handlers/backspace.ts @@ -0,0 +1,171 @@ +import { setCaretPosition } from "@src/utils/caretUtils"; +import { KeyHandlerContext } from "../types"; +import { updateEditorState, decreaseIndent, findBlockByIndex } from "../utils"; + +export const handleBackspaceKey = ( + e: React.KeyboardEvent, + ctx: KeyHandlerContext, +) => { + const { + editorCRDT, + pageId, + clientId, + setEditorState, + sendBlockDeleteOperation, + sendBlockUpdateOperation, + sendCharInsertOperation, + sendCharDeleteOperation, + } = ctx; + + const { currentBlock } = editorCRDT; + if (!currentBlock) return; + + const currentContent = currentBlock.crdt.read(); + const currentCharNodes = currentBlock.crdt.spread(); + const currentIndex = editorCRDT.LinkedList.spread().findIndex((block) => + block.id.equals(currentBlock.id), + ); + + const update = () => updateEditorState(editorCRDT, setEditorState); + + if (currentContent === "") { + e.preventDefault(); + + if (currentBlock.indent > 0) { + decreaseIndent(editorCRDT, currentBlock, pageId, setEditorState, sendBlockUpdateOperation); + return; + } + + 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; + prevBlock.crdt.currentCaret = prevBlock.crdt.read().length; + update(); + return; + } + } + + 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(); + } + update(); + return; + } + + const prevBlock = currentIndex > 0 ? editorCRDT.LinkedList.findByIndex(currentIndex - 1) : null; + + if (prevBlock) { + sendBlockDeleteOperation(editorCRDT.localDelete(currentIndex, undefined, pageId)); + + let targetIndex = currentIndex - 1; + let targetBlock = findBlockByIndex(editorCRDT, targetIndex); + + while (targetBlock && targetBlock.type === "hr") { + targetIndex -= 1; + targetBlock = findBlockByIndex(editorCRDT, targetIndex); + } + + if (targetBlock && targetBlock.type !== "hr") { + targetBlock.crdt.currentCaret = targetBlock.crdt.read().length; + editorCRDT.currentBlock = targetBlock; + setCaretPosition({ + blockId: targetBlock.id, + position: targetBlock.crdt.read().length, + pageId, + }); + } + + update(); + } + + return; + } + + const { currentCaret } = currentBlock.crdt; + if (currentCaret === 0) { + if (currentBlock.indent > 0) { + decreaseIndent(editorCRDT, currentBlock, pageId, setEditorState, sendBlockUpdateOperation); + update(); + return; + } + + 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(); + } + update(); + return; + } + + 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 = findBlockByIndex(editorCRDT, targetIndex); + + while (targetBlock && targetBlock.type === "hr") { + targetIndex -= 1; + targetBlock = findBlockByIndex(editorCRDT, targetIndex); + } + + if (targetBlock && prevBlock.type === "hr") { + editorCRDT.currentBlock = targetBlock; + editorCRDT.currentBlock.crdt.currentCaret = targetBlock.crdt.read().length; + update(); + return; + } + + const prevBlockEndCaret = prevBlock.crdt.read().length; + + for (let i = 0; i < currentContent.length; i++) { + const currentCharNode = currentCharNodes[i]; + sendCharInsertOperation( + prevBlock.crdt.localInsert( + prevBlockEndCaret + i, + currentContent[i], + prevBlock.id, + pageId, + clientId, + currentCharNode.style, + currentCharNode.color, + currentCharNode.backgroundColor, + ), + ); + } + + currentContent.split("").forEach(() => { + sendCharDeleteOperation(currentBlock.crdt.localDelete(0, currentBlock.id, pageId)); + }); + + editorCRDT.currentBlock = prevBlock; + prevBlock.crdt.currentCaret = prevBlockEndCaret; + sendBlockDeleteOperation(editorCRDT.localDelete(currentIndex, undefined, pageId)); + update(); + + if (prevBlock.type === "ol" && nextBlock?.type === "ol") { + editorCRDT.LinkedList.updateAllOrderedListIndices(); + } + e.preventDefault(); + } + } +}; diff --git a/client/src/features/editor/hooks/markdowngrammer/handlers/delete.ts b/client/src/features/editor/hooks/markdowngrammer/handlers/delete.ts new file mode 100644 index 0000000..04d6ead --- /dev/null +++ b/client/src/features/editor/hooks/markdowngrammer/handlers/delete.ts @@ -0,0 +1,27 @@ +import { KeyHandlerContext } from "../types"; +import { updateEditorState } from "../utils"; + +export const handleDeleteKey = (e: React.KeyboardEvent, ctx: KeyHandlerContext) => { + if (e.nativeEvent.isComposing) return; + const { editorCRDT, pageId, sendCharDeleteOperation, setEditorState } = ctx; + + const { currentBlock } = editorCRDT; + if (!currentBlock) return; + + const currentContent = currentBlock.crdt.read(); + + if (!currentBlock.next || currentContent) return; + + const nextBlock = editorCRDT.LinkedList.getNode(currentBlock.next); + if (!nextBlock) return; + + sendCharDeleteOperation( + currentBlock.crdt.localDelete( + editorCRDT.LinkedList.spread().findIndex((b) => b.id.equals(currentBlock.id)) + 1, + currentBlock.id, + pageId, + ), + ); + + updateEditorState(editorCRDT, setEditorState); +}; diff --git a/client/src/features/editor/hooks/markdowngrammer/handlers/enter.ts b/client/src/features/editor/hooks/markdowngrammer/handlers/enter.ts new file mode 100644 index 0000000..68d6863 --- /dev/null +++ b/client/src/features/editor/hooks/markdowngrammer/handlers/enter.ts @@ -0,0 +1,103 @@ +import { BlockCRDT } from "@noctaCrdt/Crdt"; +import { getAbsoluteCaretPosition } from "@src/utils/caretUtils"; +import { KeyHandlerContext } from "../types"; +import { createNewBlock, updateEditorState } from "../utils"; + +export const handleEnterKey = (e: React.KeyboardEvent, ctx: KeyHandlerContext) => { + const { + editorCRDT, + pageId, + clientId, + setEditorState, + sendBlockInsertOperation, + sendBlockUpdateOperation, + sendCharInsertOperation, + sendCharDeleteOperation, + } = ctx; + + const { currentBlock } = editorCRDT; + if (!currentBlock || e.nativeEvent.isComposing) return; + + e.preventDefault(); + + const caretPosition = getAbsoluteCaretPosition(e.currentTarget); + const currentContent = currentBlock.crdt.read(); + const currentCharNodes = currentBlock.crdt.spread(); + const currentIndex = editorCRDT.LinkedList.spread().findIndex((b) => + b.id.equals(currentBlock.id), + ); + + if (!currentContent && currentBlock.type !== "p") { + const wasOrderedList = currentBlock.type === "ol"; + currentBlock.type = "p"; + sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); + editorCRDT.currentBlock = currentBlock; + currentBlock.crdt.currentCaret = 0; + if (wasOrderedList) { + editorCRDT.LinkedList.updateAllOrderedListIndices(); + } + updateEditorState(editorCRDT, setEditorState); + return; + } + + if (!currentContent && currentBlock.type === "p") { + const operation = createNewBlock(editorCRDT, currentIndex + 1); + operation.node.indent = currentBlock.indent; + operation.node.crdt = new BlockCRDT(editorCRDT.client); + + sendBlockInsertOperation({ type: "blockInsert", node: operation.node, pageId }); + editorCRDT.currentBlock = operation.node; + editorCRDT.currentBlock.crdt.currentCaret = 0; + updateEditorState(editorCRDT, setEditorState); + return; + } + + const afterContent = currentContent.slice(caretPosition); + const afterCharNodes = currentCharNodes.slice(caretPosition); + + const operation = createNewBlock(editorCRDT, currentIndex + 1); + const newBlock = operation.node; + newBlock.indent = currentBlock.indent; + + if (currentBlock.type === "ol") { + newBlock.listIndex = currentBlock.listIndex! + 1; + } + + sendBlockInsertOperation({ type: "blockInsert", node: newBlock, pageId }); + + if (afterContent) { + afterContent.split("").forEach((char, i) => { + const charNode = afterCharNodes[i]; + sendCharInsertOperation( + newBlock.crdt.localInsert( + i, + char, + newBlock.id, + pageId, + clientId, + charNode.style, + charNode.color, + charNode.backgroundColor, + ), + ); + }); + + for (let i = currentContent.length - 1; i >= caretPosition; i--) { + sendCharDeleteOperation(currentBlock.crdt.localDelete(i, currentBlock.id, pageId)); + } + } + + if (["ul", "ol", "checkbox"].includes(currentBlock.type)) { + newBlock.type = currentBlock.type; + sendBlockUpdateOperation(editorCRDT.localUpdate(newBlock, pageId)); + } + + editorCRDT.currentBlock = newBlock; + newBlock.crdt.currentCaret = 0; + + if (currentBlock.type === "ol") { + editorCRDT.LinkedList.updateAllOrderedListIndices(); + } + + updateEditorState(editorCRDT, setEditorState); +}; diff --git a/client/src/features/editor/hooks/markdowngrammer/handlers/homeend.ts b/client/src/features/editor/hooks/markdowngrammer/handlers/homeend.ts new file mode 100644 index 0000000..574cd22 --- /dev/null +++ b/client/src/features/editor/hooks/markdowngrammer/handlers/homeend.ts @@ -0,0 +1,20 @@ +import { setCaretPosition } from "@src/utils/caretUtils"; +import { KeyHandlerContext } from "../types"; + +export const handleHomeEndKey = ( + e: React.KeyboardEvent, + ctx: KeyHandlerContext, +) => { + const { editorCRDT, pageId } = ctx; + + const { currentBlock } = editorCRDT; + if (!currentBlock) return; + + currentBlock.crdt.currentCaret = e.key === "Home" ? 0 : currentBlock.crdt.read().length; + + setCaretPosition({ + blockId: currentBlock.id, + position: currentBlock.crdt.currentCaret, + pageId, + }); +}; diff --git a/client/src/features/editor/hooks/markdowngrammer/handlers/page.ts b/client/src/features/editor/hooks/markdowngrammer/handlers/page.ts new file mode 100644 index 0000000..11b2b39 --- /dev/null +++ b/client/src/features/editor/hooks/markdowngrammer/handlers/page.ts @@ -0,0 +1,54 @@ +import { setCaretPosition } from "@src/utils/caretUtils"; +import { KeyHandlerContext } from "../types"; + +export const handlePageUpKey = (e: React.KeyboardEvent, ctx: KeyHandlerContext) => { + const { editorCRDT, pageId } = ctx; + + e.preventDefault(); + + const { currentBlock } = editorCRDT; + if (!currentBlock) return; + + const currentCaretPosition = currentBlock.crdt.currentCaret; + const headBlock = editorCRDT.LinkedList.getNode(editorCRDT.LinkedList.head); + if (!headBlock) return; + + headBlock.crdt.currentCaret = Math.min(currentCaretPosition, headBlock.crdt.read().length); + + editorCRDT.currentBlock = headBlock; + + setCaretPosition({ + blockId: headBlock.id, + position: currentCaretPosition, + pageId, + }); +}; + +export const handlePageDownKey = ( + e: React.KeyboardEvent, + ctx: KeyHandlerContext, +) => { + const { editorCRDT, pageId } = ctx; + + e.preventDefault(); + + const { currentBlock } = editorCRDT; + if (!currentBlock) return; + + const currentCaretPosition = currentBlock.crdt.currentCaret; + let lastBlock = currentBlock; + + while (lastBlock.next && editorCRDT.LinkedList.getNode(lastBlock.next)) { + lastBlock = editorCRDT.LinkedList.getNode(lastBlock.next)!; + } + + lastBlock.crdt.currentCaret = Math.min(currentCaretPosition, lastBlock.crdt.read().length); + + editorCRDT.currentBlock = lastBlock; + + setCaretPosition({ + blockId: lastBlock.id, + position: currentCaretPosition, + pageId, + }); +}; diff --git a/client/src/features/editor/hooks/markdowngrammer/handlers/space.ts b/client/src/features/editor/hooks/markdowngrammer/handlers/space.ts new file mode 100644 index 0000000..8b60d41 --- /dev/null +++ b/client/src/features/editor/hooks/markdowngrammer/handlers/space.ts @@ -0,0 +1,41 @@ +import { checkMarkdownPattern } from "@src/features/editor/utils/markdownPatterns"; +import { getAbsoluteCaretPosition } from "@src/utils/caretUtils"; +import { KeyHandlerContext } from "../types"; +import { updateEditorState } from "../utils"; + +export const handleSpaceKey = (e: React.KeyboardEvent, ctx: KeyHandlerContext) => { + const { editorCRDT, pageId, setEditorState, sendBlockUpdateOperation, sendCharDeleteOperation } = + ctx; + + const { currentBlock } = editorCRDT; + if (!currentBlock) return; + + const selection = window.getSelection(); + if (!selection) return; + + const currentContent = currentBlock.crdt.read(); + const currentCaret = getAbsoluteCaretPosition(e.currentTarget); + const markdownElement = checkMarkdownPattern(currentContent); + + if (markdownElement && currentCaret === markdownElement.length && currentBlock.type === "p") { + e.preventDefault(); + + // 마크다운 패턴 매칭 시 타입 변경하고 내용 비우기 + currentBlock.type = markdownElement.type; + + for (let i = 0; i < markdownElement.length; i++) { + const op = currentBlock.crdt.localDelete(0, currentBlock.id, pageId); + sendCharDeleteOperation(op); + } + + sendBlockUpdateOperation(editorCRDT.localUpdate(currentBlock, pageId)); + currentBlock.crdt.currentCaret = 0; + editorCRDT.currentBlock = currentBlock; + + if (markdownElement.type === "ol") { + editorCRDT.LinkedList.updateAllOrderedListIndices(); + } + + updateEditorState(editorCRDT, setEditorState); + } +}; diff --git a/client/src/features/editor/hooks/markdowngrammer/handlers/tab.ts b/client/src/features/editor/hooks/markdowngrammer/handlers/tab.ts new file mode 100644 index 0000000..bc7ad72 --- /dev/null +++ b/client/src/features/editor/hooks/markdowngrammer/handlers/tab.ts @@ -0,0 +1,42 @@ +import { KeyHandlerContext } from "../types"; +import { decreaseIndent, updateEditorState } from "../utils"; + +export const handleTabKey = (e: React.KeyboardEvent, ctx: KeyHandlerContext) => { + const { editorCRDT, pageId, setEditorState, sendBlockUpdateOperation } = ctx; + const { currentBlock } = editorCRDT; + if (!currentBlock || e.nativeEvent.isComposing) return; + + e.preventDefault(); + + if (currentBlock) { + if (e.shiftKey) { + // shift + tab: 들여쓰기 감소 + if (currentBlock.indent > 0) { + decreaseIndent(editorCRDT, currentBlock, pageId, setEditorState, sendBlockUpdateOperation); + updateEditorState(editorCRDT, setEditorState); + } + } else { + if (!currentBlock.prev) return; + + const parentIndent = + editorCRDT.LinkedList.nodeMap[JSON.stringify(currentBlock.prev)]?.indent ?? 0; + + const maxIndent = Math.min( + parentIndent + 1, // 부모 indent + 1 + 2, // 들여쓰기 최대 indent + ); + + // 현재 indent가 허용된 최대값보다 작을 때만 들여쓰기 증가 + 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(editorCRDT, setEditorState); + } + } + } +}; From fd3d2300239f02e78e4186a50e8f24de5bf4c5bb Mon Sep 17 00:00:00 2001 From: Ludovico7 Date: Sun, 4 May 2025 02:10:06 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20Editor=EC=97=90=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=ED=99=94=EB=90=9C=20useMarkdownGrammer=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/editor/Editor.tsx | 16 ++- .../editor/hooks/markdowngrammer/index.ts | 132 ++++++++++++++++++ 2 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 client/src/features/editor/hooks/markdowngrammer/index.ts diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index e181196..044fbda 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -10,6 +10,7 @@ import { useSocketStore } from "@src/stores/useSocketStore.ts"; import { setCaretPosition, getAbsoluteCaretPosition } from "@src/utils/caretUtils.ts"; import { editorContainer, addNewBlockButton } from "./Editor.style"; import { Block } from "./components/block/Block"; +import { useMarkdownGrammer2 } from "./hooks/markdowngrammer/index.ts"; import { useBlockDragAndDrop } from "./hooks/useBlockDragAndDrop"; import { useBlockOperation } from "./hooks/useBlockOperation.ts"; import { useBlockOptionSelect } from "./hooks/useBlockOption"; @@ -101,9 +102,20 @@ export const Editor = memo(({ testKey, pageId, serializedEditorData }: EditorPro sendCharInsertOperation, }); - const { handleKeyDown: onKeyDown, handleInput: handleHrInput } = useMarkdownGrammer({ + // const { handleKeyDown: onKeyDown, handleInput: handleHrInput } = useMarkdownGrammer({ + // editorCRDT: editorCRDT.current, + // editorState, + // setEditorState, + // pageId, + // clientId, + // sendBlockInsertOperation, + // sendBlockDeleteOperation, + // sendBlockUpdateOperation, + // sendCharDeleteOperation, + // sendCharInsertOperation, + // }); + const { handleKeyDown: onKeyDown, handleInput: handleHrInput } = useMarkdownGrammer2({ editorCRDT: editorCRDT.current, - editorState, setEditorState, pageId, clientId, diff --git a/client/src/features/editor/hooks/markdowngrammer/index.ts b/client/src/features/editor/hooks/markdowngrammer/index.ts new file mode 100644 index 0000000..27a45df --- /dev/null +++ b/client/src/features/editor/hooks/markdowngrammer/index.ts @@ -0,0 +1,132 @@ +import { EditorCRDT } from "@noctaCrdt/Crdt"; +import { BlockLinkedList } from "@noctaCrdt/LinkedList"; +import { Block } from "@noctaCrdt/Node"; +import { + RemoteBlockInsertOperation, + RemoteBlockDeleteOperation, + RemoteCharDeleteOperation, + RemoteCharInsertOperation, + RemoteBlockUpdateOperation, +} from "@noctaCrdt/types/Interfaces"; +import { useCallback } from "react"; +import { handleArrowKey } from "./handlers/arrow"; +import { handleBackspaceKey } from "./handlers/backspace"; +import { handleDeleteKey } from "./handlers/delete"; +import { handleEnterKey } from "./handlers/enter"; +import { handleHomeEndKey } from "./handlers/homeend"; +import { handlePageDownKey, handlePageUpKey } from "./handlers/page"; +import { handleSpaceKey } from "./handlers/space"; +import { handleTabKey } from "./handlers/tab"; +import { KeyHandlerContext } from "./types"; + +interface useMarkdownGrammerProps { + editorCRDT: EditorCRDT; + setEditorState: React.Dispatch< + React.SetStateAction<{ + clock: number; + linkedList: BlockLinkedList; + }> + >; + pageId: string; + clientId: number; + sendBlockInsertOperation: (operation: RemoteBlockInsertOperation) => void; + sendBlockDeleteOperation: (operation: RemoteBlockDeleteOperation) => void; + sendCharDeleteOperation: (operation: RemoteCharDeleteOperation) => void; + sendCharInsertOperation: (operation: RemoteCharInsertOperation) => void; + sendBlockUpdateOperation: (operation: RemoteBlockUpdateOperation) => void; +} + +export const useMarkdownGrammer2 = ({ + editorCRDT, + setEditorState, + pageId, + clientId, + sendBlockInsertOperation, + sendBlockDeleteOperation, + sendCharDeleteOperation, + sendCharInsertOperation, + sendBlockUpdateOperation, +}: useMarkdownGrammerProps) => { + const ctx: KeyHandlerContext = { + editorCRDT, + setEditorState, + pageId, + clientId, + sendBlockInsertOperation, + sendBlockDeleteOperation, + sendCharDeleteOperation, + sendCharInsertOperation, + sendBlockUpdateOperation, + }; + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const { key } = e; + + // 키와 핸들러 매핑 + const handlerMap: Record< + string, + (e: React.KeyboardEvent, ctx: KeyHandlerContext) => void + > = { + Enter: handleEnterKey, + Backspace: handleBackspaceKey, + Tab: handleTabKey, + Delete: handleDeleteKey, + Home: handleHomeEndKey, + End: handleHomeEndKey, + PageUp: handlePageUpKey, + PageDown: handlePageDownKey, + ArrowUp: handleArrowKey, + ArrowDown: handleArrowKey, + ArrowLeft: handleArrowKey, + ArrowRight: handleArrowKey, + " ": handleSpaceKey, + }; + + const handler = handlerMap[key]; + if (handler) { + handler(e, ctx); + } + }, + [ctx], + ); + + const handleInput = useCallback( + (block: Block, newContent: string) => { + if (newContent === "---") { + const currentContent = block.crdt.read(); + currentContent.split("").forEach((_) => { + const operationNode = block.crdt.localDelete(0, block.id, pageId); + sendCharDeleteOperation(operationNode); + }); + + block.type = "hr"; + sendBlockUpdateOperation(editorCRDT.localUpdate(block, pageId)); + + // 새로운 블록 생성 + const currentIndex = editorCRDT.LinkedList.spread().findIndex((b) => b.id.equals(block.id)); + const operation = editorCRDT.localInsert(currentIndex + 1, ""); + operation.node.type = "p"; + sendBlockInsertOperation({ type: "blockInsert", node: operation.node, pageId }); + + editorCRDT.currentBlock = operation.node; + editorCRDT.currentBlock.crdt.currentCaret = 0; + + setEditorState({ + clock: editorCRDT.clock, + linkedList: editorCRDT.LinkedList, + }); + + return true; + } + return false; + }, + [ + editorCRDT, + sendCharDeleteOperation, + sendBlockUpdateOperation, + sendBlockInsertOperation, + pageId, + ], + ); + return { handleKeyDown, handleInput }; +};