올해 두 번의 프로젝트를 통해 Editor.js 라이브러리를 활용한 에디터 제작을 경험했습니다.
Editor.js는 공식 문서에 API와 관련 자료가 잘 정리되어 있고, 다양한 플러그인을 제공해 필요한 기능을 확장하며 개발하기에 매우 유용한 라이브러리입니다.
에디터를 개발해야 하는 상황이라면 Editor.js 라이브러리를 정말 추천합니다‼️
하지만, 아직 상용화 사례가 많지 않아 참고할 수 있는 개발 사례가 부족하다는 점 아쉬운 부분이었습니다.
특히, 특정 기능을 구현하려고 할 때 Editor.js의 어떤 API를 활용해야 하는지 명확히 이해하기 어려운 경우가 많았습니다.
예를 들어, 에디터에서 블록 데이터를 커스터마이징 하거나 플러그인을 직접 개발해야 할 때, 문서를 읽는 것만으로는 실제 구현 방법을 구체적으로 떠올리기가 쉽지 않았습니다.
또한, 플러그인 간의 의존성이나 데이터 구조와 관련된 문제를 해결하는 과정에서 시행착오를 겪기도 했습니다.
하지만 반대로 Editor.js 내부 구조를 뜯어보고 분석하며 Editor.js의 동작 원리와 API 구조를 더 깊이 이해할 수 있는 계기가 되었습니다.
이번 글에서는 두 번의 프로젝트를 진행하며 경험한 시행착오를 공유하고, Editor.js를 사용해보고자 하는 분들에게 조금이나마 도움이 될 수 있도록 라이브러리 활용법과 주요 개념을 정리해보려고 합니다.
참고로, 본 포스트에서는 React를 사용한 예제를 중심으로 설명하지만, Editor.js는 Modern JS의 Class 기반으로 동작하기 때문에 사용하는 언어와 관계없이 동일한 개념을 적용할 수 있다는 점 염두에 두시면 좋겠습니다.
Editor.js?
Editor.js는 블록 기반의 JavaScript 에디터로, 구조화된 데이터 작성을 목표로 설계된 라이브러리입니다. 전통적인 WYSIWYG 에디터와 달리, Editor.js는 작성된 콘텐츠를 구조화된 JSON 형태로 저장하기 때문에, 백엔드 저장, API 전송 등 다양한 데이터 처리 작업에 적합합니다.
Editor.js는 다음과 같은 주요 특징이 있어, 다양한 프로젝트에서 유용하게 활용될 수 있습니다.
- 블록 기반 구조: 텍스트, 이미지, 비디오 등 콘텐츠를 블록 단위로 작성 및 조작할 수 있습니다.
- 확장 가능한 플러그인 시스템: 공식 문서에서 제공하는 커스텀 플러그인을 활용하거나, 새로운 플러그인을 직접 개발할 수 있어 확장성이 뛰어납니다.
- 구조화된 데이터 저장: 콘텐츠를 블록 단위의 JSON 데이터로 저장하여 다양한 플랫폼에서 데이터를 유연하게 활용할 수 있습니다.
- 간결한 UI / Modern JS 기반: 사용자가 작성에만 집중할 수 있도록 간결한 UI를 제공하며, ES6+ Class 기반 설계를 통해 React, Vue 등 다양한 프레임워크와 쉽게 통합할 수 있습니다.
Editor.js 설치 및 적용
터미널에서 npm 또는 yarn을 사용해 Editor.js를 설치할 수 있습니다.
npm install @editorjs/editorjs
yarn add @editorjs/editorjs
설치 후, Editor.js를 초기화하는 코드입니다.
import React, { useEffect } from 'react';
import EditorJS from '@editorjs/editorjs';
const EditorComponent = () => {
useEffect(() => {
const editor = new EditorJS({
holder: 'editorjs', // 에디터를 렌더링할 DOM 요소의 ID
tools: {
paragraph, // 예시 도구들
header,
// ...
},
placeholder: '여기에 내용을 입력하세요...',
});
// 컴포넌트 언마운트 시 에디터 정리
return () => {
editor.isReady
.then(() => editor.destroy())
.catch((err) => console.error('Editor.js cleanup failed:', err));
};
}, []);
return <div id="editorjs"></div>;
};
export default EditorComponent;
이때 holder는 에디터를 렌더링할 HTML 요소의 ID를 지정합니다.
Block Tool
Editor.js의 핵심은 블록 단위로 콘텐츠를 작성하고 관리하는 데 있습니다.
각 블록은 특정한 Tool(도구)을 기반으로 동작하며, 플러그인을 사용하거나 직접 커스텀 Tool을 개발해 확장할 수 있습니다.
Editor.js에서 Tool은 각 블록의 기능과 렌더링 방식을 정의합니다.
기본적으로 제공되는 Tool을 활용하거나, 필요에 따라 커스텀 Tool을 개발해 블록 단위로 콘텐츠를 더욱 유연하게 관리할 수 있습니다.
아래처럼 기본 플러그인을 활용해 빠르게 Block Tool 빠르게 적용할 수도 있습니다.
import EditorJS from '@editorjs/editorjs';
import Paragraph from '@editorjs/Paragraph';
const editor = new EditorJS({
holder: 'editorjs',
tools: {
header: {
class: Paragraph, // Paragraph Tool 클래스 지정
inlineToolbar: true, // 인라인 툴바 활성화
},
},
});
그러나 프로젝트 요구 사항 맞게 커스텀 Tool을 제작하는 것이 진정한 관건이겠죠.
Editor.js는 이를 위해 BlockTool API라는 강력한 도구를 제공합니다. 이를 통해 원하는 형태의 블록을 정의하고 삽입하는 기능을 추가할 수 있습니다.
제가 실제 프로젝트에서 구분선 Block을 개발했던 예시를 들어보겠습니다. 우선 전체 코드를 한번 살펴보고, 주요 부분들을 설명드리겠습니다.
import "./index.css";
import {
API,
BlockTool,
BlockToolConstructorOptions,
BlockToolData,
ToolboxConfig,
PasteConfig,
} from "@editorjs/editorjs";
export default class Delimiter implements BlockTool {
static get isReadOnlySupported(): boolean {
return true;
}
static get contentless(): boolean {
return true;
}
private api: API;
private _CSS: {
block: string;
wrapper: string;
delimiter: string;
active: string;
};
private data: BlockToolData;
private _element: HTMLDivElement;
constructor({ data, api }: BlockToolConstructorOptions) {
this.api = api;
this._CSS = {
block: this.api.styles.block,
wrapper: "ce-delimiter",
delimiter: "delimiter",
active: "ce-delimiter--active",
};
this.data = {
...data,
align: data.align || "left",
};
this._element = this.drawView();
}
drawView(): HTMLDivElement {
const wrapper = document.createElement("div");
const delimiter = document.createElement("hr");
wrapper.classList.add(this._CSS.wrapper, this._CSS.block);
delimiter.classList.add(this._CSS.delimiter);
delimiter.addEventListener("click", () => {
delimiter.classList.add(this._CSS.active);
document.addEventListener("keydown", this.handleKeyDown);
});
document.addEventListener(
"click",
(e: Event) => {
if (!this._element.contains(e.target as Node)) {
delimiter.classList.remove(this._CSS.active);
document.removeEventListener("keydown", this.handleKeyDown);
}
},
true
);
if (this.data) {
delimiter.style.backgroundImage = `url(${this.data.url})`;
delimiter.style.backgroundPosition =
this.data.align === "center" ? "50% 50%" : this.data.imagePosition;
wrapper.appendChild(delimiter);
}
this.applyAlignment(delimiter);
return wrapper;
}
private handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Backspace") {
e.preventDefault();
this.api.blocks.delete();
}
};
applyAlignment(element: HTMLDivElement) {
element.classList.remove("align-left", "align-center");
if (this.data.align === "center") {
element.classList.add("align-center");
}
if (this.data.align === "left") {
element.classList.add("align-left");
}
}
render(): HTMLDivElement {
return this._element;
}
save(): BlockToolData {
return {
url: this.data.url,
imagePosition: this.data.imagePosition,
align: this.data.align,
};
}
static get toolbox(): ToolboxConfig {
return {
icon: "🖼️",
title: "delimiter",
};
}
static get pasteConfig(): PasteConfig {
return { tags: ["HR"] };
}
onPaste(): void {
this.data = {};
}
}
위 코드에서, 제가 생각했을 때 커스텀 Block Tool을 제작하기 위한 중요한 메서드와 속성을 정리해 봤습니다.
1. constructor(data, api) - 초기화 작업
constructor는 커스텀 블록의 초기화를 담당하는 메서드로, 필수적으로 구현해야 합니다. 여기서 주로 두 가지 요소인 data와 api를 받아옵니다.
constructor에서는 필요한 옵션들을 쉽게 넘겨받아 설정할 수 있습니다. Editor.js 공식 코드에서는 주로 config라는 변수를 사용해 이러한 옵션들을 처리하고 있습니다. 예를 들어, header 블록을 사용할 때 config를 통해 레벨 설정을 하여 커스터마이즈 하는 방식입니다.
header: {
class: Header,
inlineToolbar: [],
config: {
levels: [1, 2, 3],
defaultLevel: 1,
},
},
api는 Editor.js가 제공하는 Core API에 접근할 수 있게 해주는 역할을 합니다. 이를 통해 블록을 삽입하거나 삭제하는 등 에디터와 관련된 다양한 동작을 처리할 수 있습니다.
Core API는 다음과 같은 구성 요소로 이루어져 있습니다:
export interface API {
blocks: Blocks;
caret: Caret;
tools: Tools;
events: Events;
listeners: Listeners;
notifier: Notifier;
sanitizer: Sanitizer;
saver: Saver;
selection: Selection;
styles: Styles;
toolbar: Toolbar;
inlineToolbar: InlineToolbar;
tooltip: Tooltip;
i18n: I18n;
readOnly: ReadOnly;
ui: Ui;
}
특히 blocks API는 블록을 삽입하고 삭제하며, 현재 선택된 블록의 인덱스를 확인하는 등의 기능을 제공합니다. 이런 기능들은 복잡한 블록 구조를 다룰 때 매우 유용합니다. 예를 들어, 사용자 입력에 따라 동적으로 새로운 블록을 추가하거나 삭제해야 하는 경우, blocks API의 도움을 받아 쉽게 구현할 수 있습니다. 공식 문서에서 이러한 Core API의 자세한 설명을 확인할 수 있는데, 처음 Editor.js를 사용한다면 공식문서의 내용 꼼꼼히 살펴보는 것이 많은 도움이 될 것입니다.
Core API
This page lists the reference documentation for Editor.js API's. You can access it both from the Editor instance or from the Plugins constructor: Methods for creating, inserting and removing Blocks Methods for getting information about Selection inside the
editorjs.io
data는 블록의 내부 데이터를 담고 있어, 해당 블록이 어떤 상태나 정보를 저장할 것인지 초기화할 수 있습니다.
블록의 데이터를 관리하는 데 있어서 data는 중요한 역할을 합니다. 커스텀 블록을 개발할 때는 내부 상태나 입력 값을 저장하기 위해 this.data를 사용합니다.
예를 들어, 이미지 블록을 만든다면 이미지의 URL, 위치, 정렬 정보 등을 this.data에 저장하게 됩니다.
데이터가 블록 내에서 추가적으로 업데이트되거나 변경된다면, 이 data를 정확하게 업데이트하는 것이 중요합니다.
이는 최종적으로 블록을 저장할 때 save() 메서드를 통해 명확한 데이터 구조로 export 될 수 있도록 하기 위함입니다.
아래는 간단한 save() 메서드의 예시입니다:
save(): BlockToolData {
return {
url: this.data.url,
imagePosition: this.data.imagePosition,
align: this.data.align,
};
}
2. render() - 블록을 화면에 표시
render() 메서드는 블록이 에디터에 어떻게 렌더링 될지를 결정하는 중요한 메서드입니다.
코드에서는 _element라는 HTML 요소를 반환해, 에디터가 이 블록을 화면에 그리도록 합니다.
블록의 UI를 정의하고 화면에 표시하기 때문에, 커스텀 블록을 사용자가 어떻게 볼지를 결정하는 중요한 역할을 합니다. 따라서, 블록을 추가하거나 저장된 블록을 불러올 때 이 메서드가 호출됩니다.
3. save() - 블록 데이터 내보내기
위에서 언급한 대로, 해당 블록 데이터를 내보낼 때 사용하는 필수 메서드입니다.
원래는 형식으로 데이터를 정의하여 블록의 상태를 JSON 형태로 저장할 수 있습니다.
4. toolbox() - 블록을 툴바에 표시
이 메서드는 에디터의 툴박스에 표시될 블록의 아이콘과 이름을 정의합니다.
코드에서는 "🖼️" 아이콘과 "delimiter"라는 이름으로 툴박스 설정을 하고 있습니다.
툴박스 설정을 통해 사용자에게 블록을 쉽게 추가할 수 있는 UI를 제공합니다.
5. pasteConfig()
붙여 넣기 기능과 관련된 설정을 정의하는 메서드입니다.
제 예시 코드에서는 <hr> 태그가 붙여 넣어질 때 자동으로 이 블록으로 인식되도록 하고 있습니다.
붙여 넣기 처리 규칙을 지정해 사용자 경험을 매끄럽게 만들어줄 수 있는 유용한 메서드입니다.
위에서 언급한 주요 메서드들을 알고 있으면 커스텀 Block Tool을 만드는 것이 한결 수월해집니다.
BlockTool 골격 내에서, 그 외에 필요한 함수는 직접 정의하여 기능을 개발하면 됩니다.
실제로 처음부터 끝까지 Block Tool을 만드는 가이드라인은 Editor.js 공식 문서에서 확인할 수 있습니다.
커스텀 블록 제작의 기본을 다지기에 아주 좋은 자료이니 참고하시면 도움이 많이 될 것입니다.
또한, 다양한 Block Tool 플러그인은 GitHub의 Awesome Editor.js 페이지에서 찾아볼 수 있습니다.
이곳에는 정말 다양한 플러그인들이 모여 있는데요, 실제로 제가 커스텀 툴을 만들 때도 이 자료들을 많이 참고했습니다.
여러 개발자들이 구현한 다양한 커스텀 코드를 살펴보면, 어떤 식으로 기능을 추가하고 적용할 수 있을지 훨씬 더 쉽게 감을 잡을 수 있을 것입니다😊
Inline Tool
Editor.js는 블록 기반의 콘텐츠 편집을 기본으로 하면서도, 인라인 스타일이나 특정 텍스트 수정 기능을 추가할 수 있도록 Inline Tool을 제공합니다.
Inline Tool은 블록 내부의 텍스트를 강조, 링크 설정, 취소선과 같은 방식으로 꾸밀 때 사용되며, 기본적인 플러그인 또는 커스텀 Inline Tool을 통해 확장할 수 있습니다.
Inline Tool의 구조는 블록을 생성하는 Block Tool과는 다소 다릅니다.
각 Tool은 특정한 텍스트를 강조하거나 꾸미는 역할을 하기 때문에 선택된 텍스트에 대한 처리를 중심으로 구현됩니다.
아래는 제가 직접 개발한 Strikethrough(취소선) Inline Tool의 코드입니다.
이 코드를 통해 Inline Tool의 기본 메서드와 동작 방식을 설명해 보겠습니다.
import "./index.css";
import { IconStrikethrough } from "@codexteam/icons";
import {
type API,
type InlineTool,
type SanitizerConfig,
} from "@editorjs/editorjs";
import { type InlineToolConstructorOptions } from "@editorjs/editorjs/types/tools/inline-tool";
export default class Strikethrough implements InlineTool {
static get CSS(): string {
return "cdx-strike-through";
}
private button: HTMLButtonElement | undefined;
private tag: string = "S";
private api: API;
private iconClasses: { base: string; active: string };
public constructor(options: InlineToolConstructorOptions) {
this.api = options.api;
this.iconClasses = {
base: this.api.styles.inlineToolButton,
active: this.api.styles.inlineToolButtonActive,
};
}
public static isInline = true;
public render(): HTMLElement {
this.button = document.createElement("button");
this.button.type = "button";
this.button.classList.add(this.iconClasses.base);
this.button.innerHTML = this.toolboxIcon;
return this.button;
}
public surround(range: Range): void {
if (!range) {
return;
}
const termWrapper = this.api.selection.findParentTag(
this.tag,
Strikethrough.CSS
);
if (termWrapper) {
this.unwrap(termWrapper);
} else {
this.wrap(range);
}
}
public wrap(range: Range) {
const s = document.createElement(this.tag);
s.classList.add(Strikethrough.CSS);
s.appendChild(range.extractContents());
range.insertNode(s);
this.api.selection.expandToTag(s);
}
public unwrap(termWrapper: HTMLElement): void {
this.api.selection.expandToTag(termWrapper);
const sel = window.getSelection();
if (!sel) {
return;
}
const range = sel.getRangeAt(0);
if (!range) {
return;
}
const unwrappedContent = range.extractContents();
if (!unwrappedContent) {
return;
}
termWrapper.parentNode?.removeChild(termWrapper);
range.insertNode(unwrappedContent);
sel.removeAllRanges();
sel.addRange(range);
}
public checkState(): boolean {
const termTag = this.api.selection.findParentTag(
this.tag,
Strikethrough.CSS
);
this.button?.classList.toggle(this.iconClasses.active, !!termTag);
return !!termTag;
}
public get toolboxIcon(): string {
return IconStrikethrough;
}
public static get sanitize(): SanitizerConfig {
return {
u: {
class: Strikethrough.CSS,
},
};
}
}
커스텀 Inline Tool을 제작할 때 Strikethrough 코드에서 특히 중요한 메서드와 속성들을 정리하면 다음과 같습니다.
1. constructor(api) - 초기화 작업
마찬가지로 constructor는 Inline Tool의 초기화를 담당하며, api 객체를 통해 Editor.js의 Core API에 접근할 수 있습니다. Inline Tool은 기존의 Block Data를 처리하는 역할만 수행하기 때문에, Block Tool과 달리 data가 필요하지 않습니다.
2. render() - 툴바 버튼 렌더링
툴바에 표시될 버튼을 렌더링 합니다.
이 메서드에서는 버튼을 생성하고, 스타일과 아이콘을 설정한 후 이를 반환합니다.
이를 통해 사용자가 툴바에서 이 Tool을 직관적으로 사용할 수 있게 됩니다.
3. surround(range) - 선택한 텍스트 감싸기/제거하기
사용자가 특정 텍스트를 선택하고 Tool 버튼을 눌렀을 때 호출됩니다.
따라서 이 메서드를 활용하면, 선택된 텍스트가 태그로 감싸져 있으면 제거하고 그렇지 않으면 새로운 태그로 감싸는 역할을 작업을 추가할 수 있게 됩니다.
제 코드에서는 Strikethrough는 선택된 텍스트에 <s> 태그를 적용하거나, 이미 적용된 <s> 태그를 제거하도록 처리했습니다.
4. checkState() - 현재 상태 확인
커서 위치나 선택된 텍스트가 현재 Tool의 스타일이 적용되어 있는지를 확인하여, 툴바 버튼의 활성화 상태를 변경합니다. 이 메서드를 통해 사용자에게 현재 선택된 텍스트에 스타일이 적용되었는지 시각적으로 알려줄 수 있습니다.
제 코드에서는, 이를 활용하여 툴바 버튼의 활성화 상태를 toggle 하는 작업을 추가했습니다.
5. renderActions() - 추가 작업 요소 렌더링
사용자가 Tool을 선택했을 때, 추가적인 작업을 할 수 있는 UI 요소(예: URL 입력 상자)를 인라인 툴바에 렌더링 합니다.
이는 특히 복잡한 작업에 대한 추가 설정이 필요한 Tool에서 매우 유용합니다.
제 Strikethrough 예시 코드에서는 작성되지 않았지만, 예를 들어 사용자가 추가 설정이 필요하다면 renderActions() 메서드를 통해 사용자에게 모달이나 추가적인 UI 요소를 제공할 수 있습니다
public renderActions(): HTMLElement {
const wrapper = document.createElement('div');
// 추가적인 설정 UI를 만들어서 wrapper에 추가
return wrapper;
}
위에서 언급한 주요 메서드들을 잘 이해하고 활용하면 커스텀 Inline Tool을 만드는 것이 한결 수월해집니다.
Inline Tool은 블록 내부의 텍스트를 더욱 유연하게 꾸밀 수 있는 강력한 도구입니다.
특히 surround(), checkState(), renderActions() 메서드는 텍스트 스타일을 적용하거나 해제하고, 현재 상태를 관리하며, 추가 설정을 가능하게 해 줍니다.
이를 잘 활용하여 사용자에게 직관적이고 풍부한 텍스트 편집 경험을 제공할 수 있습니다.
실제로 처음부터 끝까지 Inline Tool을 만드는 가이드라인은 Editor.js 공식 문서에서 확인할 수 있습니다.
또한, 다양한 Block Tool 플러그인은 GitHub의 Awesome Editor.js 페이지에서 찾아볼 수 있습니다.
개발 팁과 회고
Editor.js에서 Block Tool과 Inline Tool을 구현할 수 있으면 거의 모든 커스텀 편집 기능을 개발할 수 있습니다.
다만, 개발하다 보면 여러 상황이 생기는 법.... 제가 실제로 에디터를 개발하면서 마주쳤던 문제들과 이를 해결하기 위해 사용했던 유용한 솔루션들을 공유해보려 합니다.
1. menubar 에디터로부터 분리하기
Editor.js를 사용하면 기본적으로 커서 왼쪽에 + 아이콘을 통해 블록을 삽입할 수 있습니다.
하지만 저는 이 메뉴를 오른쪽에 사이드바 형태로 제공해야 했습니다.
이를 구현하기 위해 Menubar와 Editor.js의 기본 블록 삽입 기능을 분리하는 작업이 필요했습니다.
Editor.js에서는 editor.blocks.insert() 메서드를 사용해 블록을 특정 위치에 삽입할 수 있는 강력한 기능을 제공합니다.
그런데 기본 동작이 활성화된 위치에 블록을 자동으로 삽입하도록 되어 있어서, 사이드바를 통해 블록을 삽입하려면 이걸 수동으로 관리해야 했습니다.
(원래 기존의 +아이콘으로 블록을 삽입하면, 활성화된 위치에 블록을 자동으로 삽입하게 되므로 고려하지 않아도 됨.)
이 문제를 해결하기 위해 Editor의 현재 블록 인덱스(currentIndex)를 전역 상태로 관리하도록 설계했습니다.
이를 통해 정확한 위치에 블록을 삽입하고, 동시에 에디터 사용성을 매끄럽게 유지할 수 있었습니다.
결과적으로 사용자는 사이드바를 통해 직관적으로 블록을 삽입할 수 있게 되었습니다.
실제로 제가 프로젝트에서 활용했던, 로직입니다. 전역 상태는 Zustand 라이브러리를 사용했습니다.
/**
* 현재 블록의 인덱스를 처리하는 함수
* 블록이 없거나 선택된 블록이 있을 때 인덱스를 업데이트
*/
const handleBlockIndex = () => {
if (!editor) return;
const blockIndex = editor.blocks.getCurrentBlockIndex();
const blocksCount = editor.blocks.getBlocksCount();
// 블록이 선택된 경우
if (blockIndex > -1) {
setCurrentBlockIndex(blockIndex);
return;
}
// 블록이 하나만 있는 경우
if (blocksCount === 1) {
const firstBlock = editor.blocks.getBlockByIndex(0);
const shouldUseFirstBlock =
firstBlock?.name === "paragraph" && firstBlock.isEmpty;
setCurrentBlockIndex(shouldUseFirstBlock ? 0 : 1);
return;
}
// 그 외의 경우 마지막 블록의 인덱스 사용
setCurrentBlockIndex(Math.max(0, blocksCount - 1));
};
/**
* 새로운 블록을 추가하는 함수
*/
const addBlock = (type: string, data: object) => {
if (!editor) return;
const currentBlock = editor.blocks.getBlockByIndex(currentBlockIndex);
const isEmptyParagraph =
currentBlock?.name === "paragraph" && currentBlock.isEmpty;
if (isEmptyParagraph) {
// 현재 블록이 빈 단락이면 교체
editor.blocks.delete(currentBlockIndex);
editor.blocks.insert(type, data, undefined, currentBlockIndex);
} else {
// 그 외의 경우 다음 위치에 삽입
editor.blocks.insert(type, data, undefined, currentBlockIndex + 1);
}
};
제가 사용성을 높이기 위해 추가적으로 고려한 사항은 비어 있는 텍스트 블록을 처리하는 것이었습니다.
블록을 삽입할 때, 활성화된 텍스트 블록(paragraph)의 내용이 비어 있다면, 해당 블록을 새 블록으로 교체되도록 처리하고자 했습니다.
이 처리가 없을 경우, 비어 있는 paragraph 블록 다음에 새 블록이 삽입되어 사용자가 추가적으로 블록을 정리해야 하는 불편함이 있었습니다.
하지만 위 코드처럼 현재 활성화된 블록이 paragraph이고 내용이 비어 있는지 확인하는 로직을 추가하여, 다음과 같이 사용성을 개선할 수 있습니다.
제 예시 코드에서도 확인할 수 있듯이, Editor.js의 API를 활용하면 다양한 기능을 효과적으로 구현할 수 있습니다.
특히 getCurrentBlockIndex, getBlockByIndex, insert, delete 이 4가지 메서드는 프로젝트 내에서 정말 많이 활용했던 유용한 메서드입니다😊
2. 에디터 전역 상태 관리
개인적으로 전역 상태 관리 라이브러리를 사용할 수 있는 환경이라면, 에디터 인스턴스를 전역으로 관리하는 것을 추천합니다.
Editor.js 인스턴스를 생성하면 해당 인스턴스를 통해 Editor.js Configuration 기능에 쉽게 접근할 수 있습니다.
위의 예시에서만 봐도 필요한 부분에서 Editor 인스턴스를 받아와서 API를 활용하는 것을 볼 수 있었죠.
또한, 필요한 곳에서 데이터를 내보내야 하는 상황이라면 아래와 같이 save 메서드를 호출하고 원하는 데이터를 통합하여 내보낼 수 있습니다.
저는 헤더에 저장 버튼을 배치해 놓고, 사용자가 버튼을 누르면 Editor Block의 데이터와 제목 데이터를 함께 내보내는 방식으로 구현했습니다.
이 과정에서 save 메서드를 활용해 데이터를 손쉽게 가져올 수 있었습니다.
editor
?.save()
.then((outputData) =>
console.log("Article data: ", { ...outputData, titleData })
)
.catch((error) => console.log("Saving failed: ", error));
Editor.js를 프로젝트에 적용하다 보면 에디터 인스턴스를 참조해야 하는 일이 생각보다 많아집니다.
전역 상태로 관리해 두면 필요한 곳에서 손쉽게 접근할 수 있어서, 결과적으로 코드가 훨씬 간결해지고 수정할 때도 편리했습니다.
Editor.js를 전역 상태로 관리할 때 유용한 점은 분명 많지만, 주의해야 할 점도 있습니다.
특히, 에디터 인스턴스를 다룰 때는 Ref를 사용해 관리해야 합니다.
그렇지 않으면 에디터가 여러 번 초기화되거나 중복 생성되는 문제가 발생할 수 있습니다.
아래는 제가 사용한 코드에서 이 문제를 방지하기 위해 적용한 방법입니다:
const EditorContent = memo(() => {
const { setEditor } = useEditorStore(); // 전역 상태로 에디터 저장
const editorInstanceRef = useRef<EditorJS | null>(null); // Ref로 인스턴스 관리
useEffect(() => {
if (!editorInstanceRef.current) {
// Editor.js 인스턴스 생성
const editorInstance = new EditorJS({
holder: "editorjs",
autofocus: true,
tools: EDITOR_JS_TOOLS,
onReady: () => {
// 플러그인 초기화
new Undo({ editor: editorInstance });
new DragDrop(editorInstance);
// 전역 상태에 에디터 인스턴스 저장
setEditor(editorInstance);
},
});
editorInstanceRef.current = editorInstance; // Ref에 에디터 인스턴스 저장
}
}, []);
return (
<S.EditorContentContainer id="editorjs" style={{ cursor: "pointer" }} />
);
});
export default EditorContent;
useRef를 활용해 에디터가 이미 생성되었는지 확인하고, 생성되지 않은 경우에만 인스턴스를 초기화하도록 처리했습니다.
3. SSR에서 Editor.js 적용하기
Editor.js는 DOM을 기반으로 동작하기 때문에 서버 사이드 렌더링(SSR) 환경에서 주의가 필요합니다.
서버 측에서는 DOM 객체에 접근할 수 없기 때문에, Editor.js의 초기화 코드는 반드시 클라이언트 측에서 실행되어야 합니다.
예를 들어, Next.js와 같은 SSR 환경에서는 useEffect를 활용하여 클라이언트에서만 Editor.js를 초기화하거나 dynamic import를 사용하는 방법을 활용할 수 있습니다.
import dynamic from 'next/dynamic';
// Editor.js 컴포넌트를 동적 로딩 (SSR 비활성화)
const EditorComponent = dynamic(() => import('../components/EditorComponent'), { ssr: false });
export default function HomePage() {
return (
<div>
<h1>Editor.js Integration in Next.js</h1>
<EditorComponent />
</div>
);
}
실제로 Nuxt.js로 첫 번째 프로젝트를 진행했을 때, CSR 및 SSR 개념에 익숙하지 않아 SSR 환경에서 Editor.js를 적용하는 데 어려움을 겪었습니다.
특히, DOM을 필요로 하는 Editor.js를 서버에서 렌더링 하려고 시도하면서 예상치 못한 에러들이 발생했죠.
이 문제를 Nuxt.js에서 동적으로 컴포넌트를 로딩하도록 설정하여 해결할 수 있었습니다.
CSR과 SSR에 대한 개념이 아직 익숙하지 않다면, 제가 작성한 아래 블로그를 참고하면 도움이 될 것입니다.
Editor.js로 개발하며 겪었던 시행착오를 바탕으로 정리한 내용입니다.
Nuxt.js를 중심으로 설명하고 있지만 Next.js에서도 동일한 개념을 적용할 수 있으니 유용할 것입니다.
https://cychann.tistory.com/entry/Nuxt3-rendering-mode-lifecycle
Nuxt.js 렌더링 이해하기: 성공적인 웹 개발의 시작
요즘 프론트엔드 개발에 Next.js나 Nuxt.js와 같은 서버 사이드 렌더링(SSR)을 제공하는 프레임워크들이 널리 사용되고 있습니다. 이번 포스팅에서는 Nuxt.js에서 지원하는 다양한 렌더링 방식인 CSR,
cychann.tistory.com
4. inlineToolbar 설정
Editor.js를 사용할 때, Block Tool을 정의하면서 각 블록마다 inlineToolbar 구성을 다르게 설정할 수 있습니다.
이를 통해 툴바의 순서를 조정하거나, 필요한 툴바만 활성화하여 커스터마이징 할 수 있습니다.
/* Block Tool */
paragraph: {
class: CustomParagraph,
inlineToolbar: [
"link",
"bold",
"underline",
"strikethrough",
"font",
"color",
"backgroundColor",
],
},
header: {
class: Header,
inlineToolbar: [],
config: {
levels: [1, 2, 3],
defaultLevel: 1,
},
},
quote: {
class: Quote,
inlineToolbar: [],
},
list: {
class: List,
inlineToolbar: [],
},
Editor.js는 기본적으로 제공하는 툴바를 통해 블록 간 전환 기능을 지원합니다.
예를 들어, paragraph 블록을 header 블록으로 변환하거나, quote 블록으로 변경하는 작업이 가능합니다.
이 과정에서 변환되는 데이터를 매핑하려면, 각 Block Class에 conversionConfig 메서드를 정의하여 export와 import 형식을 명시해야 합니다.
static get conversionConfig(): ConversionConfig {
return {
export: "text",
import: "text",
};
}
이 설정을 통해 text로 매핑된 Block들끼리 블록 변환이 가능해집니다.
다시 말해, 같은 text 형식을 사용하는 블록 간에는 데이터 손실 없이 자유롭게 전환할 수 있게 되는 것입니다.
그래도 역시 제일 유용한 팁은 Editor.js의 GitHub와 Awesome Editor.js 페이지에서 다양한 플러그인 코드를 참고하는 것이었습니다.
리스트에 있는 플러그인 코드를 거의 다 뜯어봤었는데, 정말 많은 도움이 됐습니다.
결국, Editor.js에서 제공하는 API를 얼마나 잘 활용하느냐가 커스텀 에디터 개발의 핵심인 것 같습니다.
첫 번째 Editor.js 프로젝트를 진행했을 때는 API를 제대로 이해하지 못해, 많은 호환성 문제와 예상치 못한 에러들이 발생했습니다.
복사-붙여 넣기 시 깨지거나, 되돌리기 기능이 제대로 동작하지 않거나, 데이터를 다시 렌더링 할 때 UI가 맞지 않는 등 다양한 문제가 있었습니다. 다소 아쉽게 느껴졌던 프로젝트이기도 했습니다.
하지만 어떻게 다시 에디터를 작업해야 하는 프로젝트를 받게 되었고, Editor.js API를 상세히 분석하고, 최대한 활용하는 방식으로 개발을 진행했습니다.
이 덕분에 예기치 않은 오류 발생을 크게 줄일 수 있었고, 커스텀 Tool의 구조가 일관성 있게 유지되면서 작업 효율도 크게 향상되었습니다.
Editor.js API에 대한 이해를 바탕으로 프로젝트를 진행하다 보면, 이 라이브러리가 얼마나 확장성이 좋고 구조적인 라이브러리인지 알 수 있습니다.
그래서 이렇게 회고와 더불어, 개념까지 정리해 보는 시간을 가져봤습니다.
혹시라도 Editor.js를 사용하려는 분들께 이 글이 도움이 되었으면 좋겠습니다.
제가 실제로 작업했던 프로젝트는 아래 GitHub 링크에서 확인 가능합니다:
https://github.com/cychann/React_EditorJS
GitHub - cychann/React_EditorJS: Editor.js 기반 커스텀 에디터
Editor.js 기반 커스텀 에디터. Contribute to cychann/React_EditorJS development by creating an account on GitHub.
github.com