글자 중간에 특수문자나 스페이스가 있는 경우 드래그가 안되는 문제

해당 문제는 innerHtml을 만들때 문제가 발생했습니다.

const sanitizeText = (text: string): string => {
  return text
    .replace(/<br>/g, "&nbsp;")
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#039;");
  return text.replace(/<br>/g, "\\u00A0");

기존에는 특수문자들을 html 엔티티로 변경해주었습니다. 이렇게 할 경우 다음과 같은 문제가 발생합니다

// 사용자가 따옴표 뒤에 캐럿을 위치시키면:
'Hello "| world'  // | 는 캐럿 위치

// 실제 DOM에서는 이렇게 변환되어 있음:
'Hello &quot;| world'  // 의도한 위치보다 5글자 더 뒤로 밀림

스페이스 뿐만이 아니라 위의 정규표현식에 매치되는 모든 특수문자들을 html엔티티로 변경하다보니 드래그해서 선택한 selection과 실제 텍스트 사이에 불일치가 발생했습니다.

해결

const sanitizeText = (text: string): string => {
  return text.replace(/<br>/g, "\\u00A0");
};

특수문자의 경우 그대로 입력하고, 스페이스의 경우 \\u00A0 으로 처리했습니다.


드래그 한 상태에서 키입력을 한 경우 드래그한 문자들이 사라지지 않는 문제

해당 문제는 handleKeyDown에서 백스페이스 처리만 하고 나머지 입력에 대해 처리하지 않아서 발생한 문제입니다. 드래그한 후 키입력이 들어오면 브라우저 기본동작에 따라 브라우저의 텍스트는 삭제되고 키가 입력되지만, 실제 Char 인스턴스는 삭제되지 않아 다시 키를 입력하면 setInnerHtml 함수에서 지워지지 않은 Char 노드들을 다시 렌더링하면서 발생한 문제입니다.

const deleteSelectedText = useCallback(
    (block: Block, startOffset: number, endOffset: number) => {
      for (let i = endOffset - 1; i >= startOffset; i--) {
        const operationNode = block.crdt.localDelete(i, block.id, pageId);
        sendCharDeleteOperation(operationNode);
      }
      block.crdt.currentCaret = startOffset;
    },
    [pageId, sendCharDeleteOperation],
  );

deleteSelectedText 함수를 통해 삭제 연산을 실행합니다.

const handleKeyWithSelection = useCallback(
    (
      e: React.KeyboardEvent<HTMLDivElement>,
      block: Block,
      startOffset: number,
      endOffset: number,
    ) => {
      switch (e.key) {
        case "Backspace":
        case "Delete": {
          e.preventDefault();
          deleteSelectedText(block, startOffset, endOffset);
          setEditorState({
            clock: editorCRDT.clock,
            linkedList: editorCRDT.LinkedList,
          });
          break;
        }
        // 복사, 잘라내기, 실행취소 등 조합 키는 기본 동작 허용
        case "c":
        case "v":
        case "x":
        case "z":
        case "y": {
          if (e.metaKey || e.ctrlKey) {
            // 기본 브라우저 동작 허용
            return;
          }
          deleteSelectedText(block, startOffset, endOffset);
          onKeyDown(e);
          break;
        }
        // 탐색 및 선택 관련 키
        case "Tab":
        case "ArrowLeft":
        case "ArrowRight":
        case "ArrowUp":
        case "ArrowDown":
        case "Home":
        case "End":
        case "PageUp":
        case "PageDown": {
          e.preventDefault();
          onKeyDown(e);
          break;
        }
        // 기능 키들은 기본 동작 허용
        case "F1":
        case "F2":
        case "F3":
        case "F4":
        case "F5":
        case "F6":
        case "F7":
        case "F8":
        case "F9":
        case "F10":
        case "F11":
        case "F12": {
          return;
        }
        case "Enter": {
          deleteSelectedText(block, startOffset, endOffset);
          onKeyDown(e);
          break;
        }
        case "Escape": {
          // 선택 해제만 하고 다른 동작은 하지 않음
          window.getSelection()?.removeAllRanges();
          return;
        }
        default: {
          // 일반 입력 키의 경우
          if (e.metaKey || e.ctrlKey || e.altKey) {
            // 다른 단축키들 허용
            return;
          }
          deleteSelectedText(block, startOffset, endOffset);
          onKeyDown(e);
        }
      }
    },
    [deleteSelectedText, editorCRDT, onKeyDown],
  );

handleKeyWithSelection 함수를 통해 모든 키입력에 대한 처리를 실행합니다. 각 키입력에 따른 처리를 하고 필요한 경우 onKeyDown(useMarkdownGrammer 커스텀 훅)을 실행합니다.

이 부분에서 추가적인 문제가 발생했습니다.

키입력 처리 케이스가 부족하다!

기존 useMarkdownGrammer의 경우 엔터 , 백스페이스 , 스페이스 , , 화살표 입력만 처리했습니다. 그러나 위의 5개 입력 이외에도 브라우저 동작 외에 직접 처리해줘야 하는 경우가 존재했습니다.