이번 글에서는 이전 글에서 설명했던 문법 변환 과정을 통해 어떻게 실제로 블록이 전환되는지 알아보겠습니다.

블록 기반 에디터?

노션을 사용해보면 에디터가 블록 이라는 단위로 작동한다는 것을 알 수 있습니다. 블록은 HTML 태그, 표, 데이터베이스 등과 같은 최소 단위로, 마크다운 문법을 사용할 수 있는 단위입니다. 지금 작성하는 문단도 하나의 블록으로 블록단위로 여러가지 기능을 지원합니다.

노션에서는 작성한 글을 실시간으로 마크다운 문법으로 변환이 가능합니다. 예를 들면, - 를 입력하고 스페이스바를 누르면 리스트로 변경되고, # 을 입력하고 스페이스바를 누르면 h1태그로 변경되는 식입니다. 노션에서 지원하는 모든 문법을 구현하기에는 시간적 여유가 없어 가장 많이 사용하는 문법을 먼저 구현했습니다.

이전 글에서는 마크다운 문법의 변환 과정만을 설명했는데, 마크다운 문법 이외에도 블록 추가, 삭제, 병합등의 연산도 useKeyboardHandlers에서 처리해줘야 합니다.

먼저 블록과 에디터의 구조를 알아봅시다.

Block, Editor의 데이터 구조

코드

type ElementType =
  | "p"      // 기본 텍스트 블록
  | "h1"     // 헤더 1
  | "h2"     // 헤더 2
  | "h3"     // 헤더 3
  | "ul"     // 순서 없는 리스트
  | "ol"     // 순서 있는 리스트
  | "li"     // 리스트 아이템
  | "input"  // 체크박스
  | "blockquote"; // 인용구

// 마크다운 요소의 변환 결과를 정의
interface MarkdownElement {
  type: ElementType;            // 변환될 HTML 요소 타입
  attributes?: {                // 추가 속성
    [key: string]: string;      // 예: checkbox의 type="checkbox"
  };
}

// 마크다운 문법 패턴 정의
interface MarkdownPattern {
  regex: RegExp;               // 패턴 매칭용 정규표현식
  length: number;              // 패턴의 문자 길이
  createElement: () => MarkdownElement;  // HTML 요소 생성 함수
}

interface Block {
  // 필수 속성
  id: number;                  // 블록 고유 식별자
  type: ElementType;           // 블록 유형
  content: string;             // 텍스트 내용

  // 구조 관련 속성
  depth?: number;              // 들여쓰기 레벨
  children?: Block[];          // 하위 블록 목록

  // 추가 속성
  attributes?: {
    [key: string]: any;        // 스타일, 데이터 속성 등
  };

  // 리스트 관련 속성
  listProperties?: {
    index?: number;            // 순서 있는 리스트의 번호
    bulletStyle?: string;      // 순서 없는 리스트의 기호 스타일
  };
}

interface EditorState {
  blocks: Block[];			   // 에디터의 모든 블록들을 저장하는 배열
  currentBlockId: number;	   // 현재 활성화된(포커스된) 블록의 ID
  isComposing: boolean;		   // 한글 등 조합문자 입력 중인지 여부를 나타내는 상태
}

Editor.tsx

export const Editor = ({ onTitleChange }: EditorProps) => {
  // 에디터의 상태를 관리하는 useState
  // 추후 CRDT와 연동하면서 수정 예정
  const [editorList] = useState(() => new LinkedListBlock());
  const [editorState, setEditorState] = useState<EditorState>(() => ({
    rootNode: editorList.root,
    currentNode: editorList.current,
  }));
  // 한글 컴포징 처리
  // 추후 추가 예정
  const [isComposing, setIsComposing] = useState(false);

  // 해당 블록의 textContent에 접근하기 위한 useRef
  const contentRef = useRef<HTMLDivElement | null>(null);
  // 리렌더링 후 캐럿의 위치를 변경하기 위한 커스텀 훅
  const { setCaretPosition } = useCaretManager();

  const { handleKeyDown } = useKeyboardHandlers({
    editorState,
    editorList,
    setEditorState,
    checkMarkdownPattern,
  });

  // 블록을 클릭했을 때, 현재 focus된 블록 노드를 저장
  const handleBlockClick = useCallback(
    (nodeId: string) => {
      const node = editorList.findNodeById(nodeId);
      if (node) {
        setEditorState((prev) => ({
          ...prev,
          currentNode: node,
        }));
      }
    },
    [editorList],
  );

  const handleInput = useCallback(() => {
    if (isComposing) return;

    if (contentRef.current && editorState.currentNode) {
      const newContent = contentRef.current.textContent || "";
      editorState.currentNode.content = newContent;
    }
  }, [editorState.currentNode, isComposing]);

  const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    onTitleChange(e.target.value);
  };

  // DOM 변경 이후 캐럿의 위치를 직접 이동함
  useEffect(() => {
    if (editorState.currentNode) {
        const element = document.querySelector(
          `[data-node-id="${editorState.currentNode!.id}"]`,
        ) as HTMLElement;
        if (element) {
          contentRef.current = element as HTMLDivElement;
          const caretPosition = element.textContent?.length || 0;
          setCaretPosition(element, caretPosition);
        }
    }
  }, [editorState.currentNode!.id, editorState.currentNode?.type, setCaretPosition]);

  // 실제 블록을 생성하는 함수
  //
  const renderNodes = () => {
    const nodes = editorList.traverseNodes();
    return nodes.map((node) => {
      // 리스트의 경우 ul, ol에서 내부 자식 처리
      if (node.type === "li") return;
      if (node.type === "ul" || node.type === "ol") {
        const children = [];
        let child = node.firstChild;
        while (child && child.parentNode === node) {
          children.push(child);
          child = child.nextSibling;
        }

        return (
          <node.type key={node.id}>
            {children.map((liNode) => (
              <Block
                key={liNode.id}
                node={liNode}
                isActive={liNode === editorState.currentNode}
                contentRef={liNode === editorState.currentNode ? contentRef : undefined}
                currentNodeId={editorState.currentNode?.id || ""}
                onKeyDown={handleKeyDown}
                onCompositionStart={handleCompositionStart}
                onCompositionEnd={handleCompositionEnd}
                onInput={handleInput}
                onClick={handleBlockClick}
              />
            ))}
          </node.type>
        );
      }
	 // 체크박스 처리
      if (node.type === "checkbox") {
        return (
          <div key={node.id} className={checkboxContainer}>
            <input
              type="checkbox"
              checked={node.attributes?.checked || false}
              onChange={() => {}}
              onClick={(e) => e.stopPropagation()}
              className={checkbox}
            />
            {node.firstChild && (
              <Block
                key={node.firstChild.id}
                node={node.firstChild}
                isActive={node.firstChild === editorState.currentNode}
                contentRef={node.firstChild === editorState.currentNode ? contentRef : undefined}
                currentNodeId={editorState.currentNode?.id || ""}
                onKeyDown={handleKeyDown}
                onCompositionStart={handleCompositionStart}
                onCompositionEnd={handleCompositionEnd}
                onInput={handleInput}
                onClick={handleBlockClick}
              />
            )}
          </div>
        );
      }

      // 이외의 블록들(p, h1, h2등) 생성
      return (
        <Block
          key={node.id}
          node={node}
          isActive={node === editorState.currentNode}
          contentRef={node === editorState.currentNode ? contentRef : undefined}
          currentNodeId={editorState.currentNode?.id || ""}
          onKeyDown={handleKeyDown}
          onCompositionStart={handleCompositionStart}
          onCompositionEnd={handleCompositionEnd}
          onInput={handleInput}
          onClick={handleBlockClick}
        />
      );
    });
  };

  return (
    <div className={editorContainer}>
      <div className={editorTitleContainer}>
        <input
          type="text"
          placeholder="제목을 입력하세요..."
          onChange={handleTitleChange}
          className={editorTitle}
        />
      </div>

      {renderNodes()}
    </div>
  );
};

Editor 컴포넌트는 마크다운 에디터의 핵심 컨테이너 컴포넌트로, 전체 에디터의 상태 관리와 블록 렌더링을 담당합니다. 주요 상태 및 변수

블록 렌더링 로직 Editor 컴포넌트는 renderNodes 함수를 통해 세 가지 유형의 블록을 처리합니다: