[Notion] 에디터 구현하기

차차·2024년 3월 22일
19
post-thumbnail

contenteditable 속성을 활용하여, 노션처럼 작동하는 에디터 하나부터 열까지 구현하기

↗️ 에디터 사용해보기



노션 에디터 파헤치기

에디터를 구현하기에 앞서, 노션에서는 어떤 방식으로 작동하는 지 파헤쳐보자!
노션 사용 경력.. 어언 4년 째..

메인 기능은 텍스트의 서식 전환!

  1. 명령어 입력 ⇒ 전환
  2. 명령어 입력 시작 (/) ⇒ 서식 박스 나타남 ⇒ 선택 ⇒ 전환
  3. 블럭 선택 ⇒ 전환 클릭 ⇒ 서식 박스 나타남 ⇒ 선택 ⇒ 전환

이러한 방법들을 통해 일반 텍스트 블럭을 제목, 인용, 콜아웃 등의 블럭으로 바꿀 수 있다. 결과적으로, 서식을 바꾸려면 직접 선택하거나 명령어를 입력해야 한다.

직접 선택하는 기능을 구현하려니.. 추가적인 UI 를 작성해야한다. 따라서 간단하게 명령어를 통한 서식 전환 기능을 구현하였다.

그럼 이제, 사용자 입장이 아닌 개발자 입장으로 명령어 기능을 파헤쳐보자!
블럭의 포커싱 ~ 포커스 아웃 과정을 나열하면 아래와 같을 것이다.

  1. 클릭 또는 방향키 입력을 통한 블럭 포커싱
  2. 명령어 입력 후 엔터
  3. 블럭 서식 전환
  4. 텍스트 입력
  5. 포커스 아웃 + 다른 블럭 포커싱

결국 단순하게 보면, 에디터는 여러가지 이벤트가 붙어있는 블럭 생성기인 것이다.



기능 구현하기

1차 시도

사실 contenteditable 속성이 불편한 기능이 몇 가지 있어서, 해당 속성 없이 쌩으로 구현하는 시도를 거쳤다. 내가 구상한 블럭의 생명주기(?)는 아래와 같았다.

  1. 블럭 포커싱

    <input id='block' class='p'></input>
  2. 명령어 (/h1) 입력 후 엔터

    <input id='block' class='h1'></input>
  3. 입력 후 블럭 포커스 아웃

    <h1 id='block'>제목1 내용</h1>
  • 블럭은 2가지 형태, 입력 가능한 input / 완성된 element
  • 블럭이 포커싱되면, 블럭은 input 로 바뀐다.
  • 블럭의 서식은 class 에 담는다.
  • class 가 'h1' 이라면, h1 element 로 전환되어야 하는 input 이다.

이렇게 tagName 을 왔다갔다 하면서 문서를 완성해가는 것이다.

createDOMElement

자유자재로 블럭 element 를 추가하기 위해 직접 구현한 유틸 함수이다.
tagName 과 id, class 등등의 attributes, 필요하다면 innerHTML까지 받아서 완성된 element 를 반환하는 친구이다.

export interface ElementProps {
  tag: keyof HTMLElementTagNameMap;
  attributes?: {
    [name: string]: string;
  };
}

const createDOMElement = (
  { tag, attributes }: ElementProps,
  innerHTML?: string,
) => {
  const $element = document.createElement(tag);
  for (const name in attributes) {
    $element.setAttribute(name, attributes[name]);
  }
  if (innerHTML) {
    $element.innerHTML = innerHTML;
  }
  return $element;
};

Element.replaceWith()

https://developer.mozilla.org/en-US/docs/Web/API/Element/replaceWith
input ↔ text element 왔다갔다를 위해 상당히 필수적인 메소드!
이름 그대로 element 를 그대로 교체할 수 있다.

예를 들어, 엔터키 입력이 발생했을 때는 아래와 같은 일들을 해주어야 한다.

  • 단축키 입력인가요 ? class 바꾸기
  • 그냥 엔터키인가요 ? 블럭 전환하기 + 아래에 새로운 블럭 생성하기

코드로 작성하면 이런식으로!

this.addKeyEvent("Enter", (e) => {
  const $editing = e.target as HTMLElement;

  // input 에서 엔터를 쳤다.
  if ($editing.tagName === "INPUT") {
    const $input = $editing as HTMLInputElement;
    const value = $input.value;
    const newTagName = value.substring(1);
	
    // 단축키 입력 후 엔터 : class 교체
    if (textTagMap.includes(newTagName)) {
      $input.className = newTagName;
      $input.value = "";
    } else {
    // 그냥 엔터키 입력 : replaceWith
      const newTagName = $editing.className;
      // 완성된 element 로 바꿔치기
      $input.replaceWith(
        createDOMElement(
          {
            tag: newTagName as keyof HTMLElementTagNameMap,
            attributes: { id: "block" },
          },
          $input.value,
        ),
      );
      this.createNewBlock(); // 새 블럭 생성
    }
  }
});

내용을 채우기 위한 블럭 생성 - 입력 - 블럭 완성 의 한 싸이클을 완성했다!


⬆️ 개발자 도구에서 보면, 입력중인 블록은 input 인 것을 확인할 수 있다.
(contenteditable="true" 로 되어 있는 이유는, 아래에서 설명할 시행착오 때문이다 ㅠㅠ)


⚠️ 문제점

이렇게 보기에는 괜찮다. 하지만 이 기능에는 큰 문제가 있다.
텍스트를 편집할 수 있는 element 를 활용한 것이 아니기 때문에, 모오오든 에디터 기능을 하나부터 열까지 구현해야 한다. 🥲

그냥 입력하고, 지우고 하면 되는거 아닌가? 싶지만 그렇지 않다.

  1. 블럭 중간에서 enter 입력하면? (성공)
  2. 블럭 맨앞에서 backspace 입력하면? (성공)
  3. 방향키로 왔다 갔다 하기 (실패)
  4. 클릭-드래그를 통해 블럭 상관 없이 텍스트 선택하기 (실패)

에디터 내부의 내용들이 각자 다른 element 로 존재하기 때문에, 전체를 선택하거나 전체를 지우거나.. 방향키를 자유자재로 쓰거나 하는 기본적인 기능들을 구현하는 데에 상당한 어려움이 있다.

따라서 위 사진처럼 각 블럭에 contenteditable 속성을 추가도 해봤지만.. 무용지물이었다! 편집 기능들을 활용하려면, 가장 밖에 있는 element 가 contenteditable 이어야 한다.


2차 시도

블럭의 tagName을 전환하는 방식은 포기하고, 결국 contenteditable 을 붙이기로 했다. 이제 기본적인 편집 기능이 지원된다.
레퍼런스가 많이 없어서, 직접 이것저것 실험하면서 시간을 좀 썼다.

contenteditable 의 기본 기능

기본적인 기능 중에 상당히 도움이 되는 것도 있고, 상당히 방해(?)가 되는 것도 있었다. 이런 것들을 파악을 해 놓아야 이 속성을 제대로 써먹을 수 있다고 생각한다!

1. 기본 편집 기능

textarea 에서 가능한 것들은 다 작동한다고 보면 된다.

  • 텍스트 작성, 줄바꿈(Enter), 삭제(Backspace)
  • 방향키 or 클릭을 통한 커서 이동
  • 텍스트 선택

2. 특수 편집 기능

단축키를 지원한다 💕
(ctrl 또는 command 키를 통한 단축키)

  • ctrl+a : 전체 선택
  • ctrl+u : 선택한 텍스트 <u></u>
  • ctrl+i : 선택한 텍스트 <i></i>
  • ctrl+b : 선택한 텍스트 <b></b>

3. 서식 보존 기능

설정해놓은 에디터의 스타일을 보존하기 위한 기능이라고 생각한다.
하지만.. 서식이 자유롭게 변환되어야 하는 에디터를 구현하는 데에 있어서 상당히 불편했던 기능이다.


⚠️ 문제점

contenteditable 의 문제점

서식 보존 기능으로 인한 문제

문장을 작성하고, 맨 앞에서 backspace 키를 입력하여 줄바꿈을 제거했다고 해보자. 그러면 해당 줄의 스타일이 고대로 인라인으로 들어간다.

보기에는 문제가 없어 보일 수 있다. 하지만.. backspace 를 연타해서 모든 줄바꿈을 지우게 된다면, inline style 이 야무지게 들어간 span 지옥에 갇힌 HTML 이 완성되는 수가 있다.

게다가 inline + !important 는 스타일계의 무적이기 때문에, 개발자 입장에서 제어하기가 너무 어렵다. 자체적으로 개발하는 서식 변환 기능이 작동하지 못한다.

이러한 문제도 있다. 텍스트를 붙여넣기하면 서식을 그대로 가져오고, 줄바꿈(Enter)을 아무리 연타해도 거기서 빠져나오지 못한다!

이벤트 구현의 문제

제일 슬펐던 문제이다..! 이 문제점으로 인해 contenteditable 속성을 사용하지 않으려 한 것이다.

마음대로 인라인 스타일을 갖다 붙이는 서식 보존 기능으로 인해 Enter 와 Backspace keydown 이벤트를 직접 구현해야 했다. 특히 명령어 기능을 위해, 블럭에 작성된 텍스트가 명령어와 일치하는지 확인해야 한다!

따라서, 이벤트가 발생한 블럭을 찾아서, 해당 블럭의 innerText 를 가져오는 작업이 필수적이다. 하지만.. focus/keydown 이벤트는 블럭이 아닌, 제일 위에 있는 에디터 친구에서 일어난 것으로 처리된다.

keydown 이벤트가 발생할 때 마다 e.target 을 콘솔에 출력하도록 해보았다.
위와 같이, 제일 밖에 있는 editable 친구가 이벤트 타깃이 되는 것을 확인할 수 있다. 내부 블럭까지 contenteditable 을 붙여봐도 결과는 동일했다.

결론적으로, e.target.innerHTML 로 블럭 내부 내용을 가져올 수 없다. 눈물이 앞을 가렸다..


그래도 해결하기

그치만 해결해야 했다! 이벤트 타겟으로 무언가를 할 수 없기 때문에, 커서 위치를 활용하기로 했다. Range 와 Selection 객체를 통해 현재 커서 위치를 찾고, 커서가 놓여져 있는 블럭을 사용하는 것이다.

e.target 대신, 커서 위치로 블럭 찾기

getCursorInfo

간단히 표현하면 이렇다.

  • selection : 커서가 어디에 있나요? 범위와 방향이 어떻게 되나요?
  • range : 커서로 뭘 선택했나요? 선택 영역에는 어떤 노드가 있나요?
export const getCursorInfo = () => {
  const selection = window.getSelection();
  return {
    selection,
    range: selection ? selection.getRangeAt(0) : null,
  };
};

getCurrentBlock

getCursorInfo 가 반환하는 range 값을 활용하여, 커서가 위치한 블럭을 찾는 메소드이다. e.target 을 대체해주는 친구라고 생각하면 이해가 쉽다.

export const getCurrentBlock = () => {
  const { range } = getCursorInfo();
  if (!range) return null;

  const currentElement = (
    range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
      ? range.commonAncestorContainer
      : range.commonAncestorContainer.parentElement
  ) as HTMLElement;

  const $block = currentElement.closest("#block");
  if ($block instanceof HTMLElement) {
    return currentElement.closest("#block") as HTMLElement;
  }
  return null;
};

Keydown 이벤트 구현하기

디폴트 기능인 서식 복사를 막을 수는 없다. 그냥 직접 구현해야 한다.

keydown 이벤트 추상화하기

Backspace/Enter 이벤트 모두 중복되는 로직이 있어서, 이 부분을 추상화해주었다.

여기서 주목해야할 부분은 e.isComposing 인데, 한글 키보드 입력이 꼬이지 않게 하려면 필수적이다. 문자가 조합 중일 때는 이벤트를 무시하겠다는 뜻이다. 이걸 넣어주지 않으면 이벤트가 두 번 발생하거나 해서 원하는대로 제어할 수 없다.

addKeyEvent(key: string, listener: (e: KeyboardEvent) => void): void {
  this.$target.addEventListener("keydown", (e) => {
    if (e.key !== key || e.isComposing) return;
    listener(e);
  });
}

Enter 이벤트 구현

Enter 키 입력 시 일어나야 하는 일은 두가지로 나뉘어진다.

  • 명령어를 통한 서식 전환
    • /h1 + enter : h1 태그로 전환
  • 줄바꿈

서식 전환은 위에서 작성한 replaceWith 을 사용하면 매우 쉽다. 하지만 줄바꿈에서 고려할 요소가 몇가지 있다.

블럭1: 안녕하세요 반갑[커서]습니다!
블럭2: 저는 차차에요

위처럼 커서가 위치한 상황에서 Enter 키를 누르면 이렇게 되어야 한다.

블럭1: 안녕하세요 반갑
블럭2: [커서]습니다!
블럭3: 저는 차차에요
  • 커서를 기준으로 전, 후를 나누어야 한다.
  • 엔터 이벤트가 발생한 블럭 바로 아래에 새로운 블럭이 생성되어야 한다.
  • 커서는 새로운 블럭의 맨 앞에 위치해야 한다.

모든 내용을 코드로 작성하면 아래와 같다.

this.addKeyEvent("Enter", (e) => {
  // shift + Enter 시 contenteditable 에게 맡기기
  if (e.shiftKey) return;

  const $editor = e.target as HTMLElement;
  
  // 엔터키를 누른 시점에서의, 커서가 위치한 블럭
  const $block = getCurrentBlock();
  if (!$block) return;

  // 명령어를 입력했다면?
  if ($block.innerText in enterShortcutMap) {
    // 해당 블럭을 명령어에 맞게 변환
    handleShortcut($block);
    
  // 그냥 줄바꿈이라면?
  // 커서를 기준으로 이전/이후 나누고, 사이에 줄바꿈넣기
  } else {
    const { selection, range } = getCursorInfo();
    if (!(range && selection)) return;

    // 커서 위치에 잠깐 임시 노드 추가
    range.deleteContents();
    range.insertNode(
      createDOMElement({
        tag: "span",
        attributes: {
          id: "tmpcursor",
        },
      }),
    );
	
    // 임시 노드 기준 이전 / 이후 나누기
    const [beforeCursor, afterCursor] = $block.innerHTML.split(
      '<span id="tmpcursor"></span>',
    );

    // 현재 블럭에는 이전 텍스트 넣기
    $block.innerHTML = beforeCursor;
    
    // 새로운 블럭에는 이후 텍스트 넣기
    const $newBlock = createNewBlock("div", afterCursor, false);

    // 새로운 블럭을 에디터에 추가, 줄바꿈 발생한 블럭 다음에 넣기
    $editor.insertBefore($newBlock, $block.nextSibling);
    selection.setPosition($newBlock, 0);
  }
  // 디폴트 이벤트 작업 막기 (매우 중요)
  e.preventDefault();
});

Backspace 이벤트 구현

Backspace 이벤트는 한가지 경우만 막으면 된다. (e.preventDefault)
바로, 블럭의 맨 앞에서 Backspace 를 입력했을 때이다.

블럭1 : 안녕하세요 반가워요
블럭2 : [커서]제 이름은 차차에요

이렇게 맨 앞에서 Backspace 키를 누르면,

블럭1 : 안녕하세요 반가워요[커서]제 이름은 차차에요

해당 블럭이 지워지고, 이전 블럭의 중간에 커서가 위치해야 한다.
이 기능 역시 커서 역할을 하는 임시 노드를 추가해서 구현할 수 있었다.

코드로 작성하면 이렇게 된다. (자세한 순서는 주석 참고!)

this.addKeyEvent("Backspace", (e) => {
  const $block = getCurrentBlock();
  const { range, selection } = getCursorInfo();
  if (!($block && range && selection)) return;

  // 블럭의 맨 앞에서 Backspace 를 입력했다면?
  if (
    range.startOffset === 0 &&
    range.endOffset === 0 &&
    (range.startContainer.previousSibling === null ||
     $block.innerHTML === "")
  ) {
    const $prevBlock = $block.previousElementSibling;
    // 첫번째 블럭이 아님. 이전 블럭이 있음
    if ($prevBlock) {
      // 이전 블럭으로 이동할 노드들 저장
      const contents = $block.childNodes;
      
      // 현재 블럭은 삭제
      $block.remove();

      // 이전 블럭 다음 위치에, 커서 역할하는 임시 노드 추가
      const $cursor = createDOMElement(
        {
          tag: "span",
          attributes: {
            id: "tmpcursor",
          },
        },
        "cursor",
      );
      $prevBlock.append($cursor);

      // 임시 노드에 커서 놓기
      selection.setPosition($cursor, 1);
      
      // 이전 블럭에 저장된 노드들 갖다 붙이기
      $prevBlock.append(...contents);
      
      // 임시 노드는 삭제
      $cursor.remove();
      
    // 첫번째 블럭이긴 한데, 서식이 있다면?
    } else if ($block.tagName !== "div") {
      // 기본 서식으로 전환
      const $newBlock = createNewBlock("div", $block.innerHTML);
      $block.replaceWith($newBlock);
    }
    // 디폴트 이벤트 막기
    // 조건(맨 앞)에 해당되지 않으면 막지 않음. 기본 편집 기능 활용
    e.preventDefault();
  }
});


완성 및 정리

이 외에도 ..

  • 입력중인 블럭을 찾아서 클래스를 붙이는 방식으로 placeholder 기능 구현
  • 텍스트 선택 + 붙여넣기 시 클립보드 텍스트가 url 라면 a 태그로 변환

이런 기능들을 추가하였다.
모든 내용은 깃헙에 가면 볼 수 있다!

contenteditable 안쓰면?

  • e.target 으로 innerHTML 을 자유롭게 가져올 수 있음
  • focus, blur 이벤트를 활용하면 됨
  • 기본적인 편집 기능 다 직접 구현해야 함.

contenteditable 쓰면?

  • 기본적인 편집 기능 지원
  • 강제 서식 복사 기능을 없애야 함 (직접 구현하는 수 밖에..)
  • e.target 사용 못함

결론

  • 서식 복사가 발생하는 Enter, Backspace 이벤트는 직접 구현
  • Range 와 Selection 객체를 활용하여 e.target 대체
  • 나머지는 contenteditable 이 해주기 때문에, 이 방법이 제일 낫다!

2개의 댓글

comment-user-thumbnail
2024년 3월 28일

저도 도전해보고 싶었는데, 확실히 고려해야할게 많네요 ㅎㅎ 고생하셨습니다

답글 달기
comment-user-thumbnail
2024년 4월 3일

TypeScript를 진짜 잘 쓰셨네요! 잘 보고 갑니다~

답글 달기