본문 바로가기

Client/Next.js
[Next.js] Toast UI Text Editor 사용하기

블로그 프로젝트를 할 때 어떤 텍스트 에디터를 사용할지의 고민은 빼놓을 수가 없다.

이전에 CKEditor를 써본 적이 있는데, 개인적으로 편리하진 않았어서 다른 에디터를 써보고 싶었다.

후보는 여러 가지가 있었다.

- react-quill 
- TinyMCE
- Toast UI Editor
- Summernote

티스토리에서는 TinyMCE를 사용하는 듯 해서 TinyMCE랑 마지막까지 고민했는데, 디자인이나 기능이 우리 프로젝트에 더 적합하다고 판단되는 Toast UI Text Editor로 결정하였다.


▪ 설치

npm install @toast-ui/react-editor

npm install @toast-ui/editor-plugin-color-syntax


- 기본은 @toast-ui/react-editor고, @toast-ui/editor-plugin-color-syntax는 코드 블록 디자인을 위해 추가로 설치했다.


▪ 에디터 기본 설정

import '@toast-ui/editor/dist/toastui-editor.css';
import 'tui-color-picker/dist/tui-color-picker.css';
import '@toast-ui/editor-plugin-color-syntax/dist/toastui-editor-plugin-color-syntax.css';
import '@toast-ui/editor/dist/i18n/ko-kr';
import { Editor } from '@toast-ui/react-editor';
import colorSyntax from '@toast-ui/editor-plugin-color-syntax';
import { HookCallback } from '@toast-ui/editor/types/editor';

const toolbarItems = [
    ['heading', 'bold', 'italic', 'strike'],
    ['hr', 'quote'],
    ['ul', 'ol', 'task'],
    ['table', 'image', 'link'],
    ['code', 'codeblock'],
];

export default function TextEditor({
    onChange,
    contents,
    isEmpty,
}: {
    onChange: (content: string) => void;
    contents: string;
    isEmpty: boolean;
}) {
    const editorRef = useRef<Editor>();

    useEffect(() => {
        if (editorRef.current) {
            if (editorRef.current instanceof Editor) {
                const editorInstance = editorRef.current.getInstance();

                if (contents && editorInstance.getHTML() !== contents) {
                    editorInstance.setHTML(contents);
                }
            }
        }
    }, [contents]);

    useEffect(() => {
        if (isEmpty) {
            editorRef.current?.getInstance().focus();
        }
    }, [isEmpty]);

    const handleChange = () => {
        const content = editorRef.current?.getInstance().getHTML();
        if (content) {
            onChange(content);
        }
    };

    return (
        <Editor
            ref={editorRef}
            height="80vh"
            initialValue=""
            placeholder="내용을 입력하세요"
            initialEditType="wysiwyg"
            hideModeSwitch={true}
            plugins={[colorSyntax]}
            toolbarItems={toolbarItems}
            language="ko-KR"
            onChange={handleChange}
        />
    );
}


텍스트 에디터를 가져다 쓰기 편하게 컴포넌트로 분리해 두었다.

toolbarItems를 간편하게 원하는 것들만 사용할 수 있어서 만족스러웠다.

const options = {
  // ...
  toolbarItems: [
    ['heading', 'bold', 'italic', 'strike'],
    ['hr', 'quote'],
    ['ul', 'ol', 'task', 'indent', 'outdent'],
    ['table', 'image', 'link'],
    ['code', 'codeblock'],
    ['scrollSync'],
  ],
}

기본 툴바! (https://github.com/nhn/tui.editor/blob/master/docs/ko/toolbar.md 참고)

추가로 설정할 수 있는 옵션들은 공식 문서에 잘 나와있다.

개인적으로 toast ui는 공식 문서가 굉장히 잘 되어 있어서 좋다.

https://nhn.github.io/tui.editor/latest/ToastUIEditorCore

 

https://nhn.github.io/tui.editor/latest/ToastUIEditorCore/

RETURNS: { Array. >Array. } - Returns the range of the selection depending on the editor mode

nhn.github.io


▪ 이미지 업로드


    <Editor
        ref={editorRef}
        height="80vh"
        initialValue=""
        placeholder="내용을 입력하세요"
        initialEditType="wysiwyg"
        hideModeSwitch={true}
        plugins={[colorSyntax]}
        toolbarItems={toolbarItems}
        language="ko-KR"
        onChange={handleChange}
        hooks={{ addImageBlobHook: handleImageUpload }}
    />

이미지를 업로드 하려면 업로드 시 어떻게 처리할지 함수를 정의한 후 hooks 에 별도로 옵션을 추가해 주어야 한다.

 

const handleImageUpload = async (
    image: File | Blob,
    callback: HookCallback,
) => {
    const formData = new FormData();
    formData.append('file', image);

    try {
        const res = await customFetch('/temporary-image', {
            queryKey: ['temporary-image'],
            method: 'POST',
            body: formData,
        });

        const imageUrl = res.data?.filepath;
        const imageName = res.data?.orgFilename;

        if (imageUrl) {
            callback(imageUrl, imageName);
        }
    } catch (error) {
        console.log('error');
    }
};

이미지를 업로드 할 경우 임시로 저장해둘 공간이 필요하다. 

그리고 게시글 최종 저장 시 백엔드에서 이미지를 발라내서 최종 게시글에 포함되지 않은 임시 이미지는 delete 하도록 처리했다.


▪ 게시글 뷰어

import { Viewer } from '@toast-ui/react-editor';

<Viewer initialValue={unescapeSpecialChars(content)} />


toast ui editor를 통해 저장한 내용은 toast ui viewer를 통해 읽어올 수 있다.

이스케이핑 되어 저장되기 때문에 읽을 때는 언이스케이핑(디코딩) 해서 넘겨주어야 한다.

const escapeMap: { [key: string]: string } = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    '\'': '&#39;',
    '/': '&#47;',
};

export function unescapeSpecialChars(str: string): string {
    return str.replace(/&amp;|&#47;|&lt;|&gt;|&quot;|&#39;/g, (entity) => {
        for (const [char, htmlEntity] of Object.entries(escapeMap)) {
            if (entity === htmlEntity) return char;
        }
        return entity;
    });
}