개요
플러그인을 사용하면 핵심 코드를 수정하지 않고도 에디터 기능을 확장할 수 있습니다. 서식 추가, 특수 콘텐츠 타입 처리, 외부 서비스 통합, 에디터 동작 사용자 정의가 가능합니다.
플러그인의 장점:
- 모듈식 아키텍처 - 기능을 독립적으로 추가/제거 가능
- 프로젝트 간 재사용 가능
- 격리된 테스트 용이
- 커뮤니티 기여
플러그인 아키텍처
플러그인은 훅, 명령, 에디터 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 LinkPlugin implements Plugin {
name = 'link';
#editor: Editor | null = null;
install(editor: Editor) {
this.#editor = editor;
// 링크 명령 등록
editor.registerCommand('link:insert', (url: string) => {
const selection = editor.getSelection();
if (selection.isCollapsed) {
// 커서 위치에 링크 삽입
editor.insertLink(url, selection);
} else {
// 선택 영역을 링크로 감싸기
editor.wrapWithLink(selection, url);
}
});
// 링크 클릭 처리
editor.element.addEventListener('click', (e) => {
const link = (e.target as HTMLElement).closest('a');
if (link && e.ctrlKey) {
// 기본 동작 허용 (링크 열기)
return;
}
if (link) {
e.preventDefault();
// 링크 편집
this.#editLink(link);
}
});
}
#editLink(link: HTMLElement) {
const url = prompt('URL 편집:', link.getAttribute('href') || '');
if (url !== null) {
link.setAttribute('href', url);
}
}
uninstall(editor: Editor) {
editor.unregisterCommand('link:insert');
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, 엣지 케이스로 테스트하세요
- 성능 고려: 핫 패스에서 비용이 큰 작업을 피하세요