diff --git a/client/src/components/block/Block.style.ts b/client/src/components/block/Block.style.ts index 42cf3100..7261d028 100644 --- a/client/src/components/block/Block.style.ts +++ b/client/src/components/block/Block.style.ts @@ -43,19 +43,19 @@ export const blockContainerStyle = cva({ ul: { display: "block", listStyleType: "disc", - listStylePosition: "inside", + listStylePosition: "outside", }, ol: { display: "block", listStyleType: "decimal", - listStylePosition: "inside", + listStylePosition: "outside", }, li: { textStyle: "display-medium16", display: "list-item", outline: "none", margin: "0", - padding: "0 0 0 spacing.md", + padding: "0 0 0 16px", }, blockquote: { borderLeft: "4px solid token(colors.gray.300)", @@ -63,7 +63,7 @@ export const blockContainerStyle = cva({ color: "gray.500", fontStyle: "italic", }, - input: {}, + checkbox: {}, }, isActive: { true: { diff --git a/client/src/components/block/Block.tsx b/client/src/components/block/Block.tsx index 8dacd70f..c84ff5a9 100644 --- a/client/src/components/block/Block.tsx +++ b/client/src/components/block/Block.tsx @@ -49,6 +49,8 @@ export const Block: React.FC = memo( } }; + const nodeType = node.type === "checkbox" ? "p" : node.type; + const commonProps = { "data-node-id": node.id, "data-depth": node.depth, @@ -68,7 +70,7 @@ export const Block: React.FC = memo( }), }; - return React.createElement(node.type, commonProps); + return React.createElement(nodeType, commonProps); }, ); diff --git a/client/src/features/editor/Editor.style.ts b/client/src/features/editor/Editor.style.ts index a2902c36..c6145d80 100644 --- a/client/src/features/editor/Editor.style.ts +++ b/client/src/features/editor/Editor.style.ts @@ -27,3 +27,24 @@ export const editorTitle = css({ color: "gray.300", }, }); + +export const checkboxContainer = css({ + display: "flex", + gap: "spacing.sm", + flexDirection: "row", + alignItems: "center", +}); + +export const checkbox = css({ + border: "1px solid", + borderColor: "gray.300", + borderRadius: "4px", + width: "16px", + height: "16px", + margin: "0 8px 0 0", + cursor: "pointer", + "&:checked": { + borderColor: "blue.500", + backgroundColor: "blue.500", + }, +}); diff --git a/client/src/features/editor/Editor.tsx b/client/src/features/editor/Editor.tsx index bfa287d9..80430873 100644 --- a/client/src/features/editor/Editor.tsx +++ b/client/src/features/editor/Editor.tsx @@ -5,7 +5,13 @@ import { useCaretManager } from "@hooks/useCaretManager"; import { useKeyboardHandlers } from "@hooks/useMarkdownGrammer"; import { LinkedListBlock } from "@utils/linkedLIstBlock"; import { checkMarkdownPattern } from "@utils/markdownPatterns"; -import { editorContainer, editorTitleContainer, editorTitle } from "./Editor.style"; +import { + editorContainer, + editorTitleContainer, + editorTitle, + checkboxContainer, + checkbox, +} from "./Editor.style"; interface EditorProps { onTitleChange: (title: string) => void; @@ -81,7 +87,7 @@ export const Editor = ({ onTitleChange }: EditorProps) => { const renderNodes = () => { const nodes = editorList.traverseNodes(); return nodes.map((node) => { - if (node.type === "li") return null; + if (node.type === "li") return; if (node.type === "ul" || node.type === "ol") { const children = []; let child = node.firstChild; @@ -110,6 +116,34 @@ export const Editor = ({ onTitleChange }: EditorProps) => { ); } + if (node.type === "checkbox") { + return ( +
+ {}} + onClick={(e) => e.stopPropagation()} + className={checkbox} + /> + {node.firstChild && ( + + )} +
+ ); + } + return ( 0) return; if (currentNode === editorState.rootNode && currentNode.type === "p") return; - e.preventDefault(); - if (currentNode.type === "li") { + if (currentNode.parentNode?.type === "checkbox") { + const { parentNode } = currentNode; + const wasRoot = parentNode === editorState.rootNode; + + let focusNode; + if (parentNode.prevNode?.type === "checkbox") { + // undefined가 될 수 있는 상황 체크 + const prevFirstChild = parentNode.prevNode.firstChild; + if (!prevFirstChild) return; + focusNode = prevFirstChild; + } else { + focusNode = editorList.createNode("p", "", parentNode.prevNode, parentNode.nextNode); + + // 포인터 관계 설정 + if (parentNode.prevNode) { + parentNode.prevNode.nextNode = focusNode; + } + if (parentNode.nextNode) { + parentNode.nextNode.prevNode = focusNode; + } + + if (wasRoot) { + editorList.root = focusNode; + } + } + + // 관계 정리 순서 변경 + // 1. 기존 연결 관계 정리 + if (parentNode.nextNode) { + parentNode.nextNode.prevNode = parentNode.prevNode; + } + if (parentNode.prevNode) { + parentNode.prevNode.nextNode = parentNode.nextNode; + } + + // 2. 부모-자식 관계 정리 + currentNode.parentNode = null; + parentNode.firstChild = null; + + // 3. 마지막으로 현재 노드의 관계 정리 + parentNode.prevNode = null; + parentNode.nextNode = null; + + // 노드 제거 + editorList.removeNode(currentNode); + editorList.removeNode(parentNode); + + setEditorState((prev) => ({ + ...prev, + rootNode: wasRoot ? focusNode : prev.rootNode, + currentNode: focusNode, + })); + } else if (currentNode.type === "li") { const { parentNode } = currentNode; if (!parentNode) return; @@ -142,11 +193,23 @@ export const useBackspaceKeyHandler = ({ setEditorState((prev) => ({ ...prev })); } else { let focusNode; - if (currentNode.prevNode?.type === "ul" || currentNode.prevNode?.type === "ol") { + if (currentNode.prevNode?.type === "checkbox") { + // 이전 노드가 체크박스면 그 체크박스의 content 노드로 포커스 + focusNode = currentNode.prevNode.firstChild; + } else if (currentNode.prevNode?.type === "ul" || currentNode.prevNode?.type === "ol") { focusNode = editorList.getLastChild(currentNode.prevNode); } else { focusNode = currentNode.prevNode || currentNode.parentNode; } + + // 현재 노드 제거 전에 연결 관계 정리 + if (currentNode.prevNode) { + currentNode.prevNode.nextNode = currentNode.nextNode; + } + if (currentNode.nextNode) { + currentNode.nextNode.prevNode = currentNode.prevNode; + } + editorList.removeNode(currentNode); if (focusNode === editorState.rootNode) { diff --git a/client/src/hooks/useMarkdownGrammer/handlers/enter.ts b/client/src/hooks/useMarkdownGrammer/handlers/enter.ts index b7feb350..632d0680 100644 --- a/client/src/hooks/useMarkdownGrammer/handlers/enter.ts +++ b/client/src/hooks/useMarkdownGrammer/handlers/enter.ts @@ -20,7 +20,65 @@ export const useEnterKeyHandler = ({ const beforeText = content.slice(0, caretPosition); const afterText = content.slice(caretPosition); - if (currentNode.type === "li") { + if (currentNode.parentNode?.type === "checkbox") { + const { parentNode } = currentNode; + if (content.length === 0) { + // 빈 체크박스에서 엔터 -> 일반 블록으로 전환 + const newNode = editorList.createNode("p", "", parentNode.prevNode, parentNode.nextNode); + + if (parentNode.prevNode) { + parentNode.prevNode.nextNode = newNode; + } + if (parentNode.nextNode) { + parentNode.nextNode.prevNode = newNode; + } + + // root 노드 업데이트 + if (parentNode === editorState.rootNode) { + editorList.root = newNode; + } + + editorList.removeNode(currentNode); + + setEditorState((prev) => ({ + ...prev, + rootNode: parentNode === prev.rootNode ? newNode : prev.rootNode, + currentNode: newNode, + })); + } else { + // 텍스트가 있는 체크박스에서 엔터 -> 새로운 체크박스 블록 생성 + currentNode.content = beforeText; + + // 새로운 체크박스 컨테이너와 컨텐츠 생성 + const newContainer = editorList.createNode( + "checkbox", + "", + parentNode, + parentNode.nextNode, + ); + const newContent = editorList.createNode("p", afterText, null, null, currentNode.depth); + + // 새 체크박스의 관계 설정 + newContainer.firstChild = newContent; + newContent.parentNode = newContainer; + + // 기존 노드들과의 연결 설정 + if (parentNode.nextNode) { + parentNode.nextNode.prevNode = newContainer; + } + parentNode.nextNode = newContainer; + + // 기본 체크박스 속성 설정 + newContainer.attributes = { + checked: false, + }; + + setEditorState((prev) => ({ + ...prev, + currentNode: newContent, + })); + } + } else if (currentNode.type === "li") { const { parentNode } = currentNode; if (!parentNode) return; @@ -140,11 +198,18 @@ export const useEnterKeyHandler = ({ } else { // 현재 텍스트의 길이가 0이면 일반 블록으로 변경 if (content.length === 0) { + /* currentNode.type = "p"; currentNode.content = ""; + */ + const newNode = editorList.createNode("p", "", currentNode, currentNode.nextNode); + if (currentNode.nextNode) { + currentNode.nextNode.prevNode = newNode; + } + currentNode.nextNode = newNode; setEditorState((prev) => ({ ...prev, - currentNode, + currentNode: newNode, })); } else { // 일반 블록은 항상 p 태그로 새 블록 생성 diff --git a/client/src/hooks/useMarkdownGrammer/handlers/space.ts b/client/src/hooks/useMarkdownGrammer/handlers/space.ts index 344b6744..290b08da 100644 --- a/client/src/hooks/useMarkdownGrammer/handlers/space.ts +++ b/client/src/hooks/useMarkdownGrammer/handlers/space.ts @@ -17,8 +17,46 @@ export const useSpaceKeyHandler = ({ e.preventDefault(); const { currentNode } = editorState; if (!currentNode) return; + if (newElement.type === "checkbox") { + const { prevNode, nextNode } = currentNode; + const wasRoot = currentNode.prevNode === null; + + // checkbox-container 노드 생성 + const containerNode = editorList.createNode( + "checkbox", + "", + prevNode, + nextNode, + currentNode.depth, + ); + + // content 노드 생성 + const contentNode = editorList.createNode("p", "", null, null, currentNode.depth + 1); + + // 노드 간 관계 설정 + containerNode.firstChild = contentNode; + contentNode.parentNode = containerNode; + + // checkbox 속성 설정 + containerNode.attributes = { + checked: false, + ...newElement.attributes, + }; + + // root 노드 업데이트 + if (wasRoot) { + editorList.root = containerNode; + } else { + if (prevNode) prevNode.nextNode = containerNode; + if (nextNode) nextNode.prevNode = containerNode; + } - if (newElement.type === "ul" || newElement.type === "ol") { + setEditorState((prev) => ({ + ...prev, + rootNode: wasRoot ? containerNode : prev.rootNode, + currentNode: contentNode, + })); + } else if (newElement.type === "ul" || newElement.type === "ol") { // 기존 노드의 위치 관계 저장 const { prevNode, nextNode } = currentNode; const wasRoot = currentNode.prevNode === null; diff --git a/client/src/types/markdown.ts b/client/src/types/markdown.ts index 738219a9..5a1f8174 100644 --- a/client/src/types/markdown.ts +++ b/client/src/types/markdown.ts @@ -1,4 +1,4 @@ -export type ElementType = "p" | "h1" | "h2" | "h3" | "ul" | "ol" | "li" | "input" | "blockquote"; +export type ElementType = "p" | "h1" | "h2" | "h3" | "ul" | "ol" | "li" | "checkbox" | "blockquote"; export interface ListProperties { index?: number; diff --git a/client/src/utils/linkedLIstBlock.ts b/client/src/utils/linkedLIstBlock.ts index 6119d4d6..47e7adf9 100644 --- a/client/src/utils/linkedLIstBlock.ts +++ b/client/src/utils/linkedLIstBlock.ts @@ -49,6 +49,23 @@ export class LinkedListBlock { // 노드 삭제 removeNode(node: EditorNode): void { + // 부모-자식 관계 정리 + if (node.parentNode) { + if (node.parentNode.firstChild === node) { + node.parentNode.firstChild = node.nextSibling; + } + node.parentNode = null; + } + + // 형제 관계 정리 + if (node.prevSibling) { + node.prevSibling.nextSibling = node.nextSibling; + } + if (node.nextSibling) { + node.nextSibling.prevSibling = node.prevSibling; + } + + // 수평 관계 정리 if (node.prevNode) { node.prevNode.nextNode = node.nextNode; } @@ -56,11 +73,23 @@ export class LinkedListBlock { node.nextNode.prevNode = node.prevNode; } + // firstChild가 있는 경우 관계 정리 + if (node.firstChild) { + node.firstChild.parentNode = null; + node.firstChild = null; + } + // root 노드인 경우 업데이트 if (node === this.root) { this.root = node.nextNode; } + // 현재 노드인 경우 업데이트 + if (node === this.current) { + this.current = node.prevNode || node.nextNode; + } + + // 노드의 모든 참조 제거 Object.keys(node).forEach((key) => { delete (node as any)[key]; }); @@ -78,6 +107,9 @@ export class LinkedListBlock { child = child.nextSibling; } } + if (node.type === "checkbox") { + return node.firstChild; + } return find(node.nextNode); }; diff --git a/client/src/utils/markdownPatterns.ts b/client/src/utils/markdownPatterns.ts index 1a7deafd..a7bbc41e 100644 --- a/client/src/utils/markdownPatterns.ts +++ b/client/src/utils/markdownPatterns.ts @@ -32,9 +32,11 @@ const MARKDOWN_PATTERNS: Record = { createElement: () => ({ type: "blockquote" }), }, checkbox: { - regex: /^>$/, - length: 1, - createElement: () => ({ type: "input" }), + regex: /^\[ ?\]$/, // "[ ]" 또는 "[]" 패턴 매칭 + length: 3, + createElement: () => ({ + type: "checkbox", + }), }, };