Quill 에디터 커스텀 모듈 개발기

Youngeui Hong·2025년 3월 10일
0
post-thumbnail

👋🏻 들어가며

최근 참여한 두 프로젝트에서 에디터를 구현할 일이 있었다.

첫 프로젝트에서 구현해야 했던 에디터는 간단한 텍스트 편집 정도 필요한 에디터여서 contenteditable 속성을 사용해서 에디터를 만들어야겠다 생각하고 진행했었다. 그런데 막상 개발을 진행하다보니 평소 에디터를 사용하며 당연하다 여겼던 요소들이 당연한 것이 아님을 깨달을 수 있었다. 커서 위치, 한글 조합, 편집 히스토리 관리 등 많은 요소들이 개발자가 관심을 기울여야 했던 요소였다.

에디터의 안정적인 동작을 위해선 굉장히 많은 작업이 필요함을 깨닫고, 다음에는 라이브러리를 활용하는 것이 좋겠다 생각했다. 그래서 두 번째 프로젝트에서는 에디터 라이브러리를 사용했는데, 나는 Quill을 사용해서 에디터를 구현했다. Quill은 에디터에 내가 원하는 기능이 없을 때는 내가 만든 모듈을 추가해서 커스텀할 수 있다는 게 가장 큰 장점이었다.

이번 프로젝트에서 구현해야 했던 주요 기능 중 하나가 각주 기능이었는데, 이 기능은 Quill에서 기본적으로 제공하는 기능이 아니어서 다른 개발자가 만든 모듈을 사용하거나 직접 개발해야 했다. 딱 우리 서비스에 맞는 각주 모듈을 하나 찾긴 했는데, 유료이고 소스코드가 공개되어 있지 않아서 사용하기가 어려웠다. 그래서 이번 프로젝트를 위해 각주 모듈을 개발했는데, 이 코드들을 정리해서 quill-footnote라는 이름의 npm 패키지로 배포했다.

이번 글에서는 이 경험에 대해서 정리해보고자 한다. 왜 Quill을 사용했는지, 그리고 커스텀 모듈을 사용하여 Quill에 내가 원하는 기능을 추가하는 방법 등에 대해 적어보았다.

🤖 라이브러리 없이 에디터 개발하기

첫 프로젝트의 경우 간단한 텍스트 편집만 필요했기 때문에 contenteditable 속성을 사용해서 에디터를 개발하기로 했다.
주로 execCommand, queryCommandState, createElement, Selection API, Range API를 사용해서 개발했다.

그런데 개발을 진행하면서 생각보다 고려해야 할 요소가 많다는 것을 깨달았다.
bold, italic 같은 간단한 포맷팅 작업은 쉽게 할 수 있지만, 요구사항이 복잡해질수록 구현이 까다로워졌다.
구현을 잘못하면 커서 위치가 갑자기 맨 앞으로 돌아가버리거나, IME(Input Method Editor) 조합을 방해해서 한글이 "안녕"이 "ㅇㅏㄴㄴㅕㅇ"과 같이 깨지는 등, 의외의 복병이 많았다.

더군다나 document.execCommand 같은 경우에는 크로스 브라우징 이슈가 있어서 deprecated된 상태였다.
직접 DOM을 조작할 때와 달리 document.execCommand를 사용하면 undo buffer(= history)가 관리되기 때문에 간단하게 구현할 수 있는 점이 좋았지만, 아무래도 크로스 브라우징 이슈가 있다보니 자유롭게 사용하기는 어려웠다.

이런 경험을 하면서 에디터 라이브러리를 사용하는 이유를 너무나도 절실히 깨닫게 되었다.. 인력이 부족한 상황에서 라이브러리 없이 에디터를 개발하는 것은 너무나도 비효율적인 작업이었다.

🤼 TinyMCE vs. Quill 👉🏻 Quill Win!

다음 프로젝트에서는 각주나 테이블 편집 등 에디터 관련 요구사항이 좀 더 복잡해졌다. 지난 프로젝트에서 뼈저리게 느낀 바가 있는 만큼 이번에는 라이브러리를 사용해서 에디터를 개발하기로 했다.

npm trends를 살펴보면 ckeditor, draft-js, quill, tinymce가 많이 사용되는데, ckeditor와 draft-js 같은 경우에는 유지 보수가 되지 않은지 오래 돼서 TinyMCE와 Quill을 최종 후보로 간추렸다.

TinyMCE

처음에는 TinyMCE가 제공하는 기본 기능이 훨씬 많아 유리해 보였다. 이미지 삽입과 테이블 편집 등 다양한 기능을 바로 사용할 수 있었다.
하지만 막상 실제 프로젝트에 사용할 수 있을지 확인해보니 제한사항이 많았다.
일단 기본적으로 오픈소스 라이브러리가 아니다보니까 커스텀할 수 있는 범위가 제한적이었다.
뿐만 아니라 에디터에 기본적으로 내장된 기능들이 많다보니 번들이 무겁고 로드하는데 꽤 많은 시간이 걸렸다. (Quill은 198.5kb, TinyMCE는 431.3kb)
처음에는 제공하는 기능이 많고 UI도 괜찮은데 왜 quill보다 npm trends가 낮은지 의아했는데, 이런 이유에서겠구나 싶었다.

Quill

반면 Quill은 API Driven Design을 기반으로 개발된 오픈소스 라이브러리라 원하는 기능을 자유롭게 확장할 수 있어서 좋았다.
Quill은 TinyMCE와는 달리 HTML을 직접 편집하지 않고 JSON 형식으로 문서 정보를 관리하는 것이 특징적이었다.
Delta라는 JSON 포맷으로 변경사항을 관리하는데, 여기에는 텍스트 정보와 포맷팅 정보가 포함되어 있다.
HTML 자체를 편집하고 inline 스타일을 적용하는 방식이면 추후에 변경하기가 어려운데, Quill을 문서 정보를 JSON 기반으로 관리하기 때문에 요구사항의 변화에 유연하게 대처할 수 있는 점이 좋았다.
그래서 이번 프로젝트에서는 Quill을 사용하기로 결정했다.

🪶 Quill 커스텀하기

Quill은 내가 사용하고 싶은 기능이 기본 에디터에 없으면, 직접 커스텀 모듈을 만들어서 해당 기능을 추가할 수 있다.

1️⃣ Custom Blot 만들기

커스텀 모듈을 만들기 위해선 먼저 모듈에서 사용할 Blot을 만들어야 한다.

Blot은 Quill에서 DOM 노드를 표현하는 방식이라고 볼 수 있다.
Blot에는 Inline Blot, Block Blot, Embed Blot 등이 있는데, 이 중 하나를 상속해서 나만의 블롯을 만들면 된다.

Blot을 사용하지 않고 직접 DOM 노드를 조작하면 Quill이 관리하는 History에 제대로 반영되지 않기 때문에 Blot을 통해 편집해야 한다.

모든 블롯에는 반드시 blotNametagName을 작성해줘야 한다. Quill은 tagName을 바탕으로 DOM Node를 생성하고, 추후 DOM Node를 탐색할 때 blotName을 사용한다.

아래 코드는 각주 번호를 구현하기 위해 작성한 Blot 코드의 예시다.

import Embed from "quill/blots/embed";

export interface FootnoteNumberValue {
  createdAt: string;
}

export class FootnoteNumber extends Embed {
  static blotName = "footnote-number";
  static tagName = "a";
  static className = "footnote-number";

  static create(value: FootnoteNumberValue): HTMLElement {
    const node = super.create() as HTMLElement;
    const footnoteId = `footnote-${value.createdAt}`;
    node.setAttribute("id", footnoteId);
    node.setAttribute("class", "footnote-number");
    node.setAttribute("data-index", "0");
    node.setAttribute("data-createdAt", value.createdAt);
    node.setAttribute("contenteditable", "false");
    node.textContent = `[0]`;
    return node;
  }

  static formats(node: HTMLElement) {
    return {
      id: node.getAttribute("id"),
      index: node.getAttribute("data-index"),
      createdAt: node.getAttribute("data-createdAt"),
      footnote: true,
    };
  }

  static value(node: HTMLElement) {
    return {
      id: node.getAttribute("id"),
      index: node.getAttribute("data-index"),
      createdAt: node.getAttribute("data-createdAt"),
    };
  }

  format(name: string, value: any): void {
    if (
      name === "update-footnote-number-index" &&
      value.id &&
      value.id === (this.domNode as HTMLElement).getAttribute("id")
    ) {
      (this.domNode as HTMLElement).setAttribute("data-index", value.index);
      this.domNode.textContent = `[${value.index}]`;
    }
  }
}

아래는 Blot을 정의하면서 자주 사용한 메서드들이다.

◾️ static create(value?: any): Node

블롯을 생성할 때 받아와야 할 인자들과 취해야 할 작업들을 정의할 수 있다.

◾️ static formats(domNode: Node)

현재 DomNode의 포맷 정보를 반환하는 메서드다.
특정 블롯에 대해 키보드 바인딩을 걸고 싶으면 여기에서 블롯을 식별할 수 있는 포맷 정보를 정의해서 반환하면 된다.

◾️ format(format: name, value: any)

블롯에 포맷을 적용할 때 사용하는 함수이다.
format과 value는 필요에 따라 내가 자유롭게 정의해서 사용하면 된다.

◾️ optimize(context: { [key: string]: any }): void

optimize 메서드는 문서 업데이트가 완료된 다음에 Quill이 DOM 트리를 최적화하기 위해 호출하는 메서드다.
예를 들어 <p><em>기울</em><em>임체</em></p>라고 표현된 부분이 있으면 <p><em>기울임체</em></p>로 최적화한다.
때로는 Quill이 자동으로 실행하는 최적화가 내가 원하는 최적화가 아닐 수 있다.
그럴 때에는 블롯에서 optimize 메서드를 오버라이딩해서 수정해주면 된다.

2️⃣ Custom Module 만들기

앞서 만든 Blot들을 바탕으로 실제로 편집하는 작업들은 Custom Module에 작성해주면 된다.
커스텀 모듈은 아래와 같이 Module을 상속한 클래스로 만들어주면 된다.

class FootnoteModule extends Module {
  //...
}

그리고 커스텀 블롯들은 register() 메서드에서 등록해줘야 사용할 수 있다.

class FootnoteModule extends Module {
  static register(): void {
    Quill.register(FootnoteNumber);
    Quill.register(FootnoteDivider);
    //...
  }
}

undo/redo 관련 History 관리

커스텀 모듈을 개발할 때 유의해야 했던 부분은 undo/redo가 매끄럽게 진행될 수 있도록 히스토리 스택을 관리하는 것이었다.

예를 들어 각주를 추가할 때는 1) 본문에 각주번호를 추가하는 작업과 2) 본문 하단에 각주 내용을 추가하는 작업이 동시에 실행되어야 한다. 반대로 실행취소를 할 때도 각주 번호와 각주 내용은 동시에 삭제되어야 한다.
그런데 히스토리 스택에 이 두 작업이 별개의 작업으로 들어가면, cmd + z를 눌러 실행 취소를 했을 때 각주 번호만 삭제되고 각주 내용만 남아있는 이상한 상황을 목격하게 된다.

Quill은 updateContents(delta: Delta, source: string = 'api'): Delta를 하나의 작업 단위로 인식하기 때문에, 히스토리 그룹핑이 필요한 경우에는 updateContents api를 여러 번 호출하지 않고 한 번만 호출하도록 했다.
이를 위해선 편집 내용을 하나의 Delta로 모으는 것이 필요한데, Delta의 compose 메서드를 사용하면 여러 Delta를 하나의 Delta로 병합할 수 있다.

그런데 구현 상 updateContents를 한 번만 실행하는 것이 어려운 경우가 있다. 이럴 때는 History 모듈의 cutoff() 메서드가 유용했다.

3️⃣ 키보드 바인딩 수정하기

때로 키보드 키의 효과를 수정하고 싶을 때가 있다. 예컨대 Backspace 키로 각주번호를 지우면 각주 내용도 함께 삭제되게 하거나, 테이블 셀은 Backspace 키로 삭제할 수 없게 막는 등으로 말이다.

이럴 때는 Quill의 키보드 바인딩을 수정해주면 된다.
키보드 바인딩 정보는 Quill 에디터를 생성할 때 modules.keyboard.bindings 옵션에 전달해주면 된다.

유의해야 할 점은 Backspace, Enter 키 등은 기본적으로 셋팅되어 있는 바인딩이 있기 때문에, 내가 새롭게 정의한 바인딩을 추가해도 우선순위 상에서 밀려서 적용이 안 될 수가 있다.

이럴 때는 키보드 바인딩을 적용할 포맷을 명시해주는 게 효과적이었다.
아래와 같이 format을 구체적으로 명시하면 기본 바인딩보다 내가 정의한 바인딩이 우선적으로 적용되었다.

import Quill from "quill";
import { FootnoteModule } from "@src/module";

export const footnoteKeyboardBindings = {
  footnoteBackspace: {
    key: "Backspace",
    format: ["footnote"],
    handler: function (this: { quill: Quill }, range: any): boolean {
      const [leaf] = this.quill.getLeaf(range.index);
      if (leaf?.statics?.blotName === "footnote-number") {
        const footnoteModule = this.quill.getModule(
          "footnote",
        ) as FootnoteModule;
        footnoteModule.deleteFootnote(leaf);
        return false;
      }
      return true;
    },
  },

  footnoteEnter: {
    key: "Enter",
    format: ["footnote-row"],
    handler: function (this: { quill: Quill }, range: any): boolean {
      const [line] = this.quill.getLine(range.index);
      return line?.statics?.blotName !== "footnote-row";
    },
  },
};

💻 관련 코드

이번에 Quill에서 각주를 작성할 수 있도록 quill-footnote 라이브러리를 만들어보았다. Quill Custom Module과 관련해서 자세한 코드는 아래 GitHub에서 확인할 수 있다.

[GitHub] quill-footnote

0개의 댓글

관련 채용 정보