Toast Editor


개인적인 포트폴리오를 만들며 간략히 사용했던 경험담으로 글을 작성해봅니다. DB 저장은 MongoDB를 사용했으나 관련 내용은 포함하지 않으니 참고해주세요. 먼저 위지윅(WYSIWYG) 에디터로 토스트 에디터를 선택한 이유부터 시작하겠습니다.

 

TOAST Editor 선택 이유


  • 공식 문서의 친절함 (공식문서)
  • MIT Lisence
  • React 지원 여부
  • Brower Support
  • 단순한 UI
  • 계속되는 업데이트


Next.js 프로젝트에서 사용했기 때문에 리액트 지원 여부를 먼저 체크했고 개발 시간 단축을 위해 중요한 공식 문서의 친절함도 중요하게 봤습니다. MIT 라이센스는 필수겠죠. 다음으로는 여러 브라우저의 지원 여부와 단순한 UI가 있었습니다. 알고 보니 NHN이라는 친숙한 기업에서 만든 라이브러리임을 알게 되었습니다.

NHN에서는 토스트 에디터 말고도 차트와 캘린더, 이미지 에디터 등 나중에 사용 가능성 있는 여러 라이브러리들이 포함되어 있어 더욱 관심이 생겼습니다. 마지막으로 리액트 말고도 뷰(Vue)에서도 지원하기 때문에 향후 유용할 것으로 판단되었습니다.

토이 프로젝트로 블로그를 만들거나 게시판을 만들어야 하는 경우 토스트 에디터는 좋은 선택이 될 수 있겠다 생각되었습니다. 토스트 UI 공식 문서에는 마크다운, 위지윅 편집 모드를 동시에 사용하거나 선택해서 사용할 수 있다는 점을 어필하고 있었습니다.

마지막으로 계속 업데이트를 하고 있다는 부분입니다. 2.0 다음으로 3.0은 21년 06월 정도에 업데이트가 진행된 것으로 보입니다.

다음 업데이트 계획 예정으로는 플러그인 생태계 확장 및 SSR 지원, 동시 편집 기능 지원 3가지로 안내되어 있습니다.

대부분 Npm 라이브러리 선택 기준도 지속 업데이트 유무를 중요하게 보기 때문에 토스트 에디터… 좋습니다..😆



Brower Support


 

Toast Eiditor - Browser Support

 

기타 위지윅 에디터


기타 선택 사항으로 CKeditor, Quill 등이 있었습니다.

 

 

Install Toast Editor


> npm i --save @toast-ui/editor # Latest Version > npm i --save @toast-ui/editor@<version> # Specific Version

 

Install Guide

 

https://nhn.github.io/tui.editor/latest/#-install

🚩 Table of Contents Collect Statistics on the Use of Open Source TOAST UI products apply Google Analytics (GA) to collect statistics on the use of open source, in order to identify how widely TOAST UI Editor is used throughout the world. It also serves

nhn.github.io

 

// WysiwygEditor.js 

import '@toast-ui/editor/dist/toastui-editor.css' 
import '@toast-ui/editor/dist/theme/toastui-editor-dark.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 { Editor } from '@toast-ui/react-editor'
import colorSyntax from '@toast-ui/editor-plugin-color-syntax'
import { useRef } from 'react' 

const WysiwygEditor = () => { 

	const editorRef = useRef(null); 
    const toolbarItems = [ ['heading', 'bold', 'italic', 'strike'], ['hr'], ['ul', 'ol', 'task'], ['table', 'link'], ['image'], ['code'], ['scrollSync'], ] 
    
    const showContent = () => { 
    	const editorIns = editorRef.current.getInstance(); 
        const contentHtml = editorIns.getHTML(); 
        const contentMark = editorIns.getMarkdown(); 
        console.log(contentHtml); 
        console.log(contentMark); 
    } 
    
    return( 
    	<> 
        	<Editor ref={editorRef} initialValue='' // 글 수정 시 사용 
            initialEditType='wysiwyg' // wysiwyg & markdown 
            hideModeSwitch={true} 
            height='500px' 
            theme={''} // '' & 'dark' 
            usageStatistics={false} 
            toolbarItems={toolbarItems} 
            plugins={[colorSyntax, ]} /> 
            
            <button onClick={showContent}>Write</button> 
        </> 
    ) 
    
} export default WysiwygEditor
import dynamic from "next/dynamic" ... 

const NoSsrWysiwyg = dynamic(()=> import('../components/wysiwyg'), { ssr : false } ) 

... 

const NewPost = () => { 

	...
    
    return(
    <> 
    	<NoSsrWysiwyg />
    </> 
    ) 
}

 

Next.js를 사용한다면, 아직 SSR 지원이 안되고 굳이 SSR이 필요 없으므로 WysiwygEditor 컴포넌트를 사용자 브라우저 단에서 렌더링 시키도록 합니다. Dynamic import를 사용하면 SSR 기능을 해제할 수 있습니다. 리액트를 사용한다면 그냥 사용하면 됩니다.

작성한 글 내용은 getHTML() / getMarkdown() 함수를 사용해서 알맞게 DB에 저장합니다.

 

Toast Editor - 이미지 업로드


이미지를 다뤄야 하는 경우 서버에 업로드 과정이 추가됩니다.

글의 내용은 DB 서버에 저장하지만 이미지는 별도의 이미지 서버에 따로 저장해야 합니다. 글 내용에는 이미지의 경로만 포함하고 있으면 됩니다.

제 경우 이미지 blob 데이터를 base64 인코딩 형식으로 DB에 저장하려 했지만, 보통은 사용하지 않는 방법이고 가뜩이나 몽고디비도 무료로 사용하고 있기에 용량 한정 무료로 사용할 수 있는 파이어베이스 Storage를 이미지 서버로 사용했습니다.

 

const toolbarItems = [ 
    ['heading', 'bold', 'italic', 'strike'], 
    ['hr'], 
    ['ul', 'ol', 'task'], 
    ['table', 'link'], 
    ['image'], // <-- 이미지 추가 툴바 
    ['code'], 
    ['scrollSync'], 
]

툴바에 이미지가 포함되어 있어야 합니다.

 

base64로 인코딩된 이미지

 

토스트 에디터에 기존 이미지 추가 기능은 선택한 이미지를 base64로 인코딩해 <img/> 태그 src 속성에 문자열로추가되는데요. 이렇게 DB에 저장하게 된다면 서버에 무리가 갈 것입니다. 몽고디비 도큐먼트 당 16MB 제한이 있고 이미지 크기를 생각한다면, 별도의 이미지 서버로 저장하는 것이 옳은 것 같습니다.

기존 이미지 추가 툴바 수정


'addImageBlobHook'을 제거하고 같은 이름으로 새로 만든 addImage()를 추가해줍니다.

// wysiwyg.js 
... 

useEffect(()=> { 
	const editorIns = editorRef.current.getInstance(); 
    editorIns.removeHook("addImageBlobHook"); //<- 제거 
    editorIns.addHook('addImageBlobHook', addImage); //<- 추가 }, 
[]); 

const addImage = async(blob, dropImage) => { 
	console.log(blob); //이미지 압축 및 서버 업로드 로직 실행 
    dropImage('이미지 경로 URL' , 'alt_text'); //에디터에 이미지 추가 
} ...


useEffect Hook을 사용해 컴포넌트 렌더링이 끝난 후 작업을 실행해줍니다. addImage()는 선택한 이미지의 blob 데이터와 에디터 내용에 이미지를 삽입해주는 dropImage() 콜백 함수를 매개변수로 받습니다. dropImage() 콜백 함수는 이미지 경로와 alt 속성 값을 넣어주면 에디터에 선택한 이미지가 들어가게 됩니다.

이미지를 서버에 저장하고 경로를 가져와 dropImage()로 전달해주면 기능구현은 마무리됩니다.

이미지 서버 관리(?)


티스토리를 예로들자면, 에디터에 사진을 올릴 때마다 전용 이미지 서버에 업로드합니다. 하지만 이미지만 업로드하고 글 작성을 안 할 경우 서버에는 사용하지 않는 이미지가 남아있을 것으로 생각되어 글 작성이 완료되면 이미지를 서버에 업로드하려 했습니다.

이미지 압축을 진행했음에도 파이어베이스 업로드 속도가 느린 건지 사진 10장만 되어도 10~20초 정도 걸리는 불상사가 발생했습니다.

역시 그때그때 서버에 업로드하는 이유가 있던 거 같습니다. 그렇다면 이미지 서버에는 사용하지 않는 이미지를 어떻게 처리할까?라는 궁금증이 생겨 곰곰이 생각해본 결과 일정 시간마다 모든 글 내용과 이미지 경로를 대조해서 삭제 처리하는 작업을 하는 건가?라는 뇌피셜을 남겨봅니다.

 

Firebase - Cloud Storage


파이어베이스는 5기가 한정 무료로 이용 가능하기 때문에 브라우저단에서 이미지 압축을 진행했습니다. 방법은 이전에 포스팅한 browser-image-compression 라이브러리를 사용했습니다.

 

 

[JS] 자바스크립트 이미지 압축 라이브러리

brower-image-compression 자바스크립트를 사용한 이미지 압축 라이브러리로 brower-image-compression을 사용하면 손쉽게 이미지의 파일 사이즈를 효과적으로 압축할 수 있다. 요즘은 스마트폰 카메라 성능

juni-official.tistory.com

파이어베이스 이미지 업로드


import { useRef, useEffect } from 'react' 
import { initializeApp } from "firebase/app";
import { getStorage, ref, uploadBytes, getDownloadURL } from "firebase/storage" 

const firebaseConfig = { 
	apiKey: process.env.NEXT_PUBLIC_APIKEY, 
    authDomain: process.env.NEXT_PUBLIC_AUTHDOMAIN, 
    projectId: process.env.NEXT_PUBLIC_PROJECTID, 
    storageBucket: process.env.NEXT_PUBLIC_STORAGEBUCKET, 
    messagingSenderId: process.env.NEXT_PUBLIC_MESSAGINGSENDERID, 
    appId: process.env.NEXT_PUBLIC_APPID, 
}; 
    
const firebaseApp = initializeApp(firebaseConfig); 
const storage = getStorage(firebaseApp); 

const WysiwygEditor = () => {

	const editorRef = useRef(null);
    useEffect(()=> { 
    	const editorIns = editorRef.current.getInstance(); 
        editorIns.removeHook("addImageBlobHook"); 
        editorIns.addHook('addImageBlobHook', addImage); 
    }, []); 
    
// ------------- image Function ------------- // 에디터에 이미지 추가 
	
    const addImage = async(blob, dropImage) => { 
    	const img = await compressImg(blob); //이미지 압축 
        const url = await uploadImage(img); //업로드된 이미지 서버 url 
        dropImage(url, 'alt_text'); //에디터에 이미지 추가 
    } 
    //이미지 업로드 
    
    const uploadImage = async(blob) => { 
    	try{ 								//firebase Storage Create Reference 파일 경로 / 파일 명 . 확장자 
        	const storageRef = ref(storage, `post_images/${generateName() + '.' + blob.type.substring(6, 10)}`); 
            //firebase upload const snapshot = await uploadBytes(storageRef, blob); 
            
            return await getDownloadURL(storageRef); 
        } catch (err){ 
        	console.log(err) return false; } 
        } 
        
    //이미지 압축 
    const compressImg = async(blob) => { 
    	try{ 
        	const options = { 
            	maxSize: 1, 
                initialQuality: 0.55 //initial 0.7 
            } 
            return await imageCompression(blob, options); 
        } catch(e){ console.log(e); } } 
        
    //랜덤 파일명 생성 
    const generateName = () => { 
    	const ranTxt = Math.random().toString(36).substring(2,10); //랜덤 숫자를 36진수로 문자열 변환 
        const date = new Date(); 
        const randomName = ranTxt+'_'+date.getFullYear()+''+date.getMonth()+1+''+date.getDate()+''+date.getHours()+''+date.getMinutes()+''+date.getMinutes()+''+date.getSeconds(); 
        return randomName; 
    } 
    
    const getContent = () => { //글 내용 HTML 문자열로 불러오기 
    	const editorIns = editorRef.current.getInstance(); 
        return editorIns.getHTML(); 
    } 
    
    const getMarkDown = () => { //글 내용 마크다운 문자열로 불러오기 
    	const editorIns = editorRef.current.getInstance(); 
        return editorIns.getMarkdown(); 
    } 
    
    return( 
    <> 
    	<Editor ref={editorRef} 
        		initialEditType='wysiwyg' 
                hideModeSwitch={true} 
                height='500px' 
                theme={''} 
                usageStatistics={false} 
                toolbarItems={toolbarItems} 
                plugins={[colorSyntax,]} 
        /> 
    </> 
    ) 
    
} export default WysiwygEditor

 

Toast Eiditor - Image


이미지 추가의 순서는 아래와 같은 로직으로 실행됐습니다.

  1. 이미지 선택
  2. 이미지 압축 진행
  3. 랜덤 파일명으로 파이어베이스 스토리지에 이미지 업로드
  4. 저장된 경로를 가져와 dropImage()에 담아 에디터에 삽입

 

Validation 확인


const getMarkDown = () => { //글 내용 마크다운 문자열로 불러오기 
	const editorIns = editorRef.current.getInstance(); 
    return editorIns.getMarkdown(); 
}

const validation_check = () => { 
	const title = titleRef.current.value.trim(); 
    const content = getMarkDown(); 
    if(title !== '' || content !== ''){
    	// DB에 저장 
    }else{ 
    	// 에러 표시 
    } 
}


마지막으로 글 내용을 html 문자열로 서버에 저장하기 전 제목이나 본문에 내용이 제대로 들어가 있는지 체크 후 DB 서버에 전송해야 합니다. 글 내용의 경우 getHTML() 메서드가 아닌 getMarkdown() 메서드로 확인해야 정확한 체크가 가능합니다.

토스트 에디터 단점


이렇게 토스트 에디터를 가볍게 사용해 봤는데요. 몇 가지 아쉬운 점이 있어서 남겨보겠습니다.  

첫 번째로 글자 크기를 선택적으로 지정할 수 없어서 아쉬웠습니다. 물론 Heading 태그들과 기본 p태그의 글자 크기는 구별되어 있지만 HTML조차 모르는 일반 사용자의 입장에서는 아쉬운 점으로 보입니다.

두 번째로는 이미지 다중 업로드와 이미지 사이즈 선택이 불가능하다는 점입니다.

CSS로 전체 이미지 속성은 건드릴 수 있으나 사용자가 직접 수정하려면 마크다운을 건드려야 합니다.

다음 업데이트에서는 플러그인 생태계 확장이 있다고 하니 기대해 봅니다.