diff --git a/@noctaCrdt/Interfaces.ts b/@noctaCrdt/Interfaces.ts index 3db39717..75f1cc3c 100644 --- a/@noctaCrdt/Interfaces.ts +++ b/@noctaCrdt/Interfaces.ts @@ -3,7 +3,17 @@ import { Block, Char } from "./Node"; import { Page } from "./Page"; import { EditorCRDT } from "./Crdt"; -export type ElementType = "p" | "h1" | "h2" | "h3" | "ul" | "ol" | "li" | "checkbox" | "blockquote"; +export type ElementType = + | "p" + | "h1" + | "h2" + | "h3" + | "ul" + | "ol" + | "li" + | "checkbox" + | "blockquote" + | "hr"; export type AnimationType = "none" | "highlight" | "gradation"; diff --git a/client/src/features/editor/Editor.style.ts b/client/src/features/editor/Editor.style.ts index 4a8e5dcb..3d9c87ae 100644 --- a/client/src/features/editor/Editor.style.ts +++ b/client/src/features/editor/Editor.style.ts @@ -14,6 +14,7 @@ export const editorContainer = css({ export const editorTitleContainer = css({ display: "flex", + gap: "4px", flexDirection: "column", width: "full", padding: "spacing.sm", diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index e8da5a63..9f99ab51 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -92,7 +92,7 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData sendCharInsertOperation, }); - const { handleKeyDown: onKeyDown } = useMarkdownGrammer({ + const { handleKeyDown: onKeyDown, handleInput: handleHrInput } = useMarkdownGrammer({ editorCRDT: editorCRDT.current, editorState, setEditorState, @@ -151,11 +151,10 @@ export const Editor = ({ onTitleChange, pageId, pageTitle, serializedEditorData const newContent = element.textContent || ""; const currentContent = block.crdt.read(); const caretPosition = getAbsoluteCaretPosition(element); - console.log({ - newContent, - currentContent, - caretPosition, - }); + + if (handleHrInput(block, newContent)) { + return; + } if (newContent.length > currentContent.length) { let charNode: RemoteCharInsertOperation; diff --git a/client/src/features/editor/components/block/Block.style.ts b/client/src/features/editor/components/block/Block.style.ts index 12964d52..133ccd57 100644 --- a/client/src/features/editor/components/block/Block.style.ts +++ b/client/src/features/editor/components/block/Block.style.ts @@ -115,7 +115,8 @@ export const textContainerStyle = cva({ }, blockquote: { borderLeft: "4px solid token(colors.gray.300)", - paddingLeft: "spacing.md", + borderRadius: "none", + paddingLeft: "8px", color: "gray.500", fontStyle: "italic", "&:empty::before": { @@ -127,6 +128,10 @@ export const textContainerStyle = cva({ content: '"텍스트를 입력하세요..."', }, }, + hr: { + borderTop: "2px solid token(colors.gray.300)", + height: "1px", + }, }, }, defaultVariants: { diff --git a/client/src/features/editor/components/block/Block.tsx b/client/src/features/editor/components/block/Block.tsx index 461cb88c..fdbac776 100644 --- a/client/src/features/editor/components/block/Block.tsx +++ b/client/src/features/editor/components/block/Block.tsx @@ -239,7 +239,7 @@ export const Block: React.FC = memo( onPaste={(e) => onPaste(e, block)} onMouseUp={handleMouseUp} onCompositionEnd={(e) => onCompositionEnd(e, block)} - contentEditable + contentEditable={block.type !== "hr"} spellCheck={false} suppressContentEditableWarning className={textContainerStyle({ diff --git a/client/src/features/editor/hooks/useBlockOption.ts b/client/src/features/editor/hooks/useBlockOption.ts index 7f294410..a65a832b 100644 --- a/client/src/features/editor/hooks/useBlockOption.ts +++ b/client/src/features/editor/hooks/useBlockOption.ts @@ -43,7 +43,11 @@ export const useBlockOptionSelect = ({ block.type = type; editorCRDT.currentBlock = block; - editorCRDT.remoteUpdate(block, pageId); + + // block의 crdt 초기화. hr 은 문자 노드가 없기 때문에 + if (block.type === "hr") { + block.crdt = new BlockCRDT(editorCRDT.client); + } sendBlockUpdateOperation({ node: block, diff --git a/client/src/features/editor/hooks/useMarkdownGrammer.ts b/client/src/features/editor/hooks/useMarkdownGrammer.ts index cf23ff73..2c55d250 100644 --- a/client/src/features/editor/hooks/useMarkdownGrammer.ts +++ b/client/src/features/editor/hooks/useMarkdownGrammer.ts @@ -11,6 +11,7 @@ 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; @@ -56,6 +57,13 @@ export const useMarkdownGrammer = ({ }); }; + const findBlock = (index: number) => { + if (index < 0) return null; + if (index >= editorCRDT.LinkedList.spread().length) return null; + + return editorCRDT.LinkedList.findByIndex(index); + }; + const currentBlockId = editorCRDT.currentBlock ? editorCRDT.currentBlock.id : null; if (!currentBlockId) return; @@ -164,9 +172,30 @@ export const useMarkdownGrammer = ({ const prevBlock = currentIndex > 0 ? editorCRDT.LinkedList.findByIndex(currentIndex - 1) : null; if (prevBlock) { + // 현재 블록 삭제 sendBlockDeleteOperation(editorCRDT.localDelete(currentIndex, undefined, pageId)); - prevBlock.crdt.currentCaret = prevBlock.crdt.spread().length; - editorCRDT.currentBlock = prevBlock; + + // 이전 편집 가능한 블록 찾기 + let targetIndex = currentIndex - 1; + let targetBlock = findBlock(targetIndex); + + while (targetBlock && targetBlock.type === "hr") { + targetIndex -= 1; + targetBlock = findBlock(targetIndex); + } + + // 편집 가능한 블록을 찾았다면 캐럿 이동 + if (targetBlock && targetBlock.type !== "hr") { + targetBlock.crdt.currentCaret = targetBlock.crdt.read().length; + editorCRDT.currentBlock = targetBlock; + setCaretPosition({ + blockId: targetBlock.id, + linkedList: editorCRDT.LinkedList, + position: targetBlock.crdt.read().length, + pageId, + }); + } + updateEditorState(); } break; @@ -190,7 +219,22 @@ export const useMarkdownGrammer = ({ // FIX: 서윤님 피드백 반영 const prevBlock = currentIndex > 0 ? editorCRDT.LinkedList.findByIndex(currentIndex - 1) : null; + if (prevBlock) { + let targetIndex = currentIndex - 1; + let targetBlock = findBlock(targetIndex); + + while (targetBlock && targetBlock.type === "hr") { + targetIndex -= 1; + targetBlock = findBlock(targetIndex); + } + if (targetBlock && prevBlock.type === "hr") { + editorCRDT.currentBlock = targetBlock; + editorCRDT.currentBlock.crdt.currentCaret = targetBlock.crdt.read().length; // 커서 이동 + updateEditorState(); + break; + } + const prevBlockEndCaret = prevBlock.crdt.spread().length; for (let i = 0; i < currentContent.length; i++) { const currentCharNode = currentCharNodes[i]; @@ -214,6 +258,7 @@ export const useMarkdownGrammer = ({ editorCRDT.currentBlock = prevBlock; editorCRDT.currentBlock.crdt.currentCaret = prevBlockEndCaret; sendBlockDeleteOperation(editorCRDT.localDelete(currentIndex, undefined, pageId)); + updateEditorState(); e.preventDefault(); } @@ -283,14 +328,18 @@ export const useMarkdownGrammer = ({ return; } - // const selection = window.getSelection(); - // const caretPosition = selection?.focusOffset || 0; const caretPosition = getAbsoluteCaretPosition(e.currentTarget); - // 이동할 블록 결정 - const targetIndex = e.key === "ArrowUp" ? currentIndex - 1 : currentIndex + 1; - const targetBlock = editorCRDT.LinkedList.findByIndex(targetIndex); - if (!targetBlock) return; + let targetIndex = e.key === "ArrowUp" ? currentIndex - 1 : currentIndex + 1; + let targetBlock = findBlock(targetIndex); + + while (targetBlock && targetBlock.type === "hr") { + targetIndex = e.key === "ArrowUp" ? targetIndex - 1 : targetIndex + 1; + targetBlock = findBlock(targetIndex); + } + + if (!targetBlock || targetBlock.type === "hr") return; + e.preventDefault(); targetBlock.crdt.currentCaret = Math.min(caretPosition, targetBlock.crdt.read().length); editorCRDT.currentBlock = targetBlock; @@ -312,14 +361,22 @@ export const useMarkdownGrammer = ({ // 왼쪽 끝에서 이전 블록으로 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; + + let targetIndex = currentIndex - 1; + let targetBlock = findBlock(targetIndex); + + while (targetBlock && targetBlock.type === "hr") { + targetIndex -= 1; + targetBlock = findBlock(targetIndex); + } + + if (targetBlock && targetBlock.type !== "hr") { + targetBlock.crdt.currentCaret = targetBlock.crdt.read().length; + editorCRDT.currentBlock = targetBlock; setCaretPosition({ - blockId: prevBlock.id, + blockId: targetBlock.id, linkedList: editorCRDT.LinkedList, - position: prevBlock.crdt.read().length, + position: targetBlock.crdt.read().length, pageId, }); } @@ -331,12 +388,19 @@ export const useMarkdownGrammer = ({ currentIndex < editorCRDT.LinkedList.spread().length - 1 ) { e.preventDefault(); // 기본 동작 방지 - const nextBlock = editorState.linkedList.findByIndex(currentIndex + 1); - if (nextBlock) { - nextBlock.crdt.currentCaret = 0; - editorCRDT.currentBlock = nextBlock; + let targetIndex = currentIndex + 1; + let targetBlock = findBlock(targetIndex); + + while (targetBlock && targetBlock.type === "hr") { + targetIndex += 1; + targetBlock = findBlock(targetIndex); + } + + if (targetBlock && targetBlock.type !== "hr") { + targetBlock.crdt.currentCaret = 0; + editorCRDT.currentBlock = targetBlock; setCaretPosition({ - blockId: nextBlock.id, + blockId: targetBlock.id, linkedList: editorCRDT.LinkedList, position: 0, pageId, @@ -359,5 +423,45 @@ export const useMarkdownGrammer = ({ [editorCRDT, editorState, setEditorState, pageId], ); - return { handleKeyDown }; + // hr은 --- 입력 시 생성되는 input 이벤트. KeyDown 입력과 관련이 없으므로 함수 분리 + 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({ 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 }; };