플러그인 개발 가이드

사용자 정의 기능으로 모델 기반 contenteditable 에디터를 확장하는 플러그인 개발 가이드입니다.

개요

플러그인을 사용하면 핵심 코드를 수정하지 않고도 에디터 기능을 확장할 수 있습니다. 서식 추가, 특수 콘텐츠 타입 처리, 외부 서비스 통합, 에디터 동작 사용자 정의가 가능합니다.

플러그인의 장점:

  • 모듈식 아키텍처 - 기능을 독립적으로 추가/제거 가능
  • 프로젝트 간 재사용 가능
  • 격리된 테스트 용이
  • 커뮤니티 기여

플러그인 아키텍처

플러그인은 훅, 명령, 에디터 API를 통해 에디터와 통합됩니다.

플러그인 인터페이스

모든 플러그인은 표준 인터페이스를 구현합니다:

interface Plugin {
  name: string;
  version?: string;
  
  // 생명주기 메서드
  install(editor: Editor): void | Promise<void>;
  uninstall?(editor: Editor): void | Promise<void>;
  
  // 선택사항: 플러그인 구성
  configure?(config: PluginConfig): void;
}

class MyPlugin implements Plugin {
  name = 'my-plugin';
  version = '1.0.0';
  
  install(editor: Editor) {
    // 훅, 명령 등 등록
  }
  
  uninstall(editor: Editor) {
    // 정리
  }
}

플러그인 생명주기

플러그인은 다음 생명주기를 거칩니다:

// 1. 플러그인 인스턴스화
const plugin = new MyPlugin();

// 2. 플러그인 설치 (에디터에 의해 호출됨)
await editor.use(plugin);
// 또는
await plugin.install(editor);

// 3. 플러그인 활성화 (이벤트, 명령 등 처리)

// 4. 플러그인 제거 (선택사항)
await plugin.uninstall?.(editor);
// 또는
editor.removePlugin(plugin);

플러그인 생성

Plugin 인터페이스를 구현하고 훅이나 명령을 등록하여 플러그인을 생성합니다.

기본 플러그인 구조

최소한의 플러그인:

class BasicPlugin implements Plugin {
  name = 'basic-plugin';
  #editor: Editor | null = null;

  install(editor: Editor) {
    this.#editor = editor;
    
    // 이벤트 리스너 등록
    editor.on('operation', this.#handleOperation.bind(this));
    
    // 명령 등록
    editor.registerCommand('basic:action', this.#handleCommand.bind(this));
  }

  uninstall(editor: Editor) {
    // 이벤트 리스너 제거
    editor.off('operation', this.#handleOperation);
    
    // 명령 등록 해제
    editor.unregisterCommand('basic:action');
    
    this.#editor = null;
  }

  #handleOperation(operation: Operation) {
    // 작업 처리
  }

  #handleCommand() {
    // 명령 처리
  }
}

훅 사용

플러그인은 에디터 생명주기와 작업에 훅을 걸 수 있습니다:

class HookPlugin implements Plugin {
  name = 'hook-plugin';

  install(editor: Editor) {
    // 작업 생명주기에 훅
    editor.hooks.beforeOperation.tap((operation) => {
      // 적용 전 작업 수정
      if (operation.type === 'insert') {
        // 작업 변환
      }
    });

    editor.hooks.afterOperation.tap((operation) => {
      // 적용 후 작업에 반응
    });

    // 렌더링에 훅
    editor.hooks.beforeRender.tap(() => {
      // 렌더 준비
    });

    editor.hooks.afterRender.tap(() => {
      // 렌더 후 정리
    });
  }
}

명령 추가

플러그인은 프로그래밍 방식으로 또는 키보드 단축키를 통해 호출할 수 있는 명령을 등록할 수 있습니다:

class CommandPlugin implements Plugin {
  name = 'command-plugin';

  install(editor: Editor) {
    // 명령 등록
    editor.registerCommand('format:bold', () => {
      const selection = editor.getSelection();
      if (selection.isCollapsed) {
        // 현재 단어나 선택 영역에 서식 적용
        editor.toggleFormat('bold', selection);
      }
    });

    // 키보드 단축키 등록
    editor.registerShortcut('Mod-b', 'format:bold');
  }
}

플러그인 예제

실제 플러그인 예제:

서식 플러그인

텍스트 서식을 추가하는 플러그인:

class FormattingPlugin implements Plugin {
  name = 'formatting';
  #editor: Editor | null = null;

  install(editor: Editor) {
    this.#editor = editor;

    // 서식 명령 등록
    editor.registerCommand('format:bold', () => this.#toggleBold());
    editor.registerCommand('format:italic', () => this.#toggleItalic());
    editor.registerCommand('format:underline', () => this.#toggleUnderline());

    // 단축키 등록
    editor.registerShortcut('Mod-b', 'format:bold');
    editor.registerShortcut('Mod-i', 'format:italic');
    editor.registerShortcut('Mod-u', 'format:underline');
  }

  #toggleBold() {
    if (!this.#editor) return;
    const selection = this.#editor.getSelection();
    this.#editor.toggleFormat('bold', selection);
  }

  #toggleItalic() {
    if (!this.#editor) return;
    const selection = this.#editor.getSelection();
    this.#editor.toggleFormat('italic', selection);
  }

  #toggleUnderline() {
    if (!this.#editor) return;
    const selection = this.#editor.getSelection();
    this.#editor.toggleFormat('underline', selection);
  }

  uninstall(editor: Editor) {
    editor.unregisterCommand('format:bold');
    editor.unregisterCommand('format:italic');
    editor.unregisterCommand('format:underline');
    this.#editor = null;
  }
}

이미지 플러그인

이미지를 처리하는 플러그인:

class ImagePlugin implements Plugin {
  name = 'image';
  #editor: Editor | null = null;
  #uploadUrl: string;

  constructor(uploadUrl: string) {
    this.#uploadUrl = uploadUrl;
  }

  install(editor: Editor) {
    this.#editor = editor;

    // 붙여넣기 처리
    editor.element.addEventListener('paste', async (e) => {
      const items = e.clipboardData?.items;
      if (!items) return;

      for (const item of items) {
        if (item.type.startsWith('image/')) {
          e.preventDefault();
          const file = item.getAsFile();
          if (file) {
            await this.#handleImagePaste(file);
          }
        }
      }
    });

    // 명령 등록
    editor.registerCommand('image:insert', async (file: File) => {
      await this.#uploadAndInsert(file);
    });
  }

  async #handleImagePaste(file: File) {
    await this.#uploadAndInsert(file);
  }

  async #uploadAndInsert(file: File) {
    if (!this.#editor) return;

    // 이미지 업로드
    const formData = new FormData();
    formData.append('image', file);
    
    const response = await fetch(this.#uploadUrl, {
      method: 'POST',
      body: formData
    });
    
    const { url } = await response.json();

    // 에디터에 삽입
    const selection = this.#editor.getSelection();
    this.#editor.insertImage(url, selection);
  }

  uninstall(editor: Editor) {
    editor.unregisterCommand('image:insert');
    this.#editor = null;
  }
}

플러그인 API

플러그인은 콘텐츠, 선택 영역, 상태를 조작하기 위한 에디터 API에 액세스할 수 있습니다.

에디터 API

핵심 에디터 메서드:

interface Editor {
  // 선택 영역
  getSelection(): Selection;
  setSelection(selection: Selection): void;

  // 콘텐츠 조작
  insertText(text: string, selection?: Selection): void;
  insertNode(node: Node, selection?: Selection): void;
  deleteContent(selection: Selection): void;
  
  // 서식
  toggleFormat(format: string, selection: Selection): void;
  hasFormat(format: string, selection: Selection): boolean;
  
  // 명령
  registerCommand(name: string, handler: Function): void;
  unregisterCommand(name: string): void;
  executeCommand(name: string, ...args: any[]): void;
  
  // 이벤트
  on(event: string, handler: Function): void;
  off(event: string, handler: Function): void;
  emit(event: string, data?: any): void;
  
  // 훅
  hooks: {
    beforeOperation: Hook;
    afterOperation: Hook;
    beforeRender: Hook;
    afterRender: Hook;
  };
}

모델 API

문서 모델 액세스:

interface Model {
  // 읽기 작업
  getNode(path: Path): Node | null;
  getText(range?: Range): string;
  
  // 쓰기 작업
  applyOperation(operation: Operation): void;
  
  // 순회
  walk(callback: (node: Node, path: Path) => void): void;
  
  // 쿼리
  findNodes(predicate: (node: Node) => boolean): Node[];
}

뷰 API

DOM 뷰 액세스:

interface View {
  // DOM 액세스
  element: HTMLElement;
  getNodeElement(node: Node): HTMLElement | null;
  
  // 렌더링
  render(): void;
  updateNode(node: Node): void;
  
  // 선택 영역
  getDOMSelection(): Selection | null;
  setDOMSelection(selection: Selection): void;
}

모범 사례

플러그인 개발의 핵심 원칙:

  • 플러그인을 집중적으로 유지: 각 플러그인은 단일하고 명확한 목적을 가져야 합니다
  • 제거 시 정리: 모든 이벤트 리스너, 명령, 참조를 제거하세요
  • 가능하면 훅 사용: 확장성을 위해 직접 API 호출보다 훅을 선호하세요
  • 오류를 우아하게 처리: 플러그인 오류가 에디터를 중단시키지 않도록 하세요
  • 플러그인 문서화: 명확한 API 문서와 예제를 제공하세요
  • 철저히 테스트: 다양한 브라우저, IME, 엣지 케이스로 테스트하세요
  • 성능 고려: 핫 패스에서 비용이 큰 작업을 피하세요

Related Pages

에디터 아키텍처

에디터 아키텍처 패턴 개요

입력 처리 & IME

입력 처리 및 IME 조합

모델-DOM 동기화

모델과 DOM 동기화