[next 14, typescript] TinyMCE 웹 에디터 라이브러리 self-hosted로 무료 사용하기 (+ 다중 이미지 업로드)

옹잉·2024년 10월 26일
0

프로젝트의 개발 환경

  • Next.js v14
  • Typescript v5
  • react v18
  • ES 모듈

TinyMCE를 적용하는 방법은

  1. 클라우드 호스팅 (월 1000회 로드 무료, 이후 유료)
  2. 패키지 매니저로 설치하는 셀프 호스팅 (무료)
  3. zip 파일에서 추출하는 셀프 호스팅 (무료)

이렇게 세 가지 방법이 있고, 나는 패키지 매니저를 사용했다.

공식 문서 - 패키지 매니저를 통한 셀프 호스팅

TinyMCE의 경우 에디터 사용하는 것 보단 나중에 환경 셋팅하는 데 더 시간이 많이 들었다.
참고할 공식 문서의 자료가 여기저기 있는 느낌?이라 React 기반 기술문서를 제대로 활용하지 못했다.
그러다 보니 js 기반으로 된 내용을 typescript에 맞게 환경을 수정하는 과정이 필요했다.


1. tinymce, @tinymce/tinymce-react, fs-extra 패키지 설치

yarn add tinymce @tinymce/tinymce-react fs-extra

2. {프로젝트}/postinstall.mjs 생성

TinyMCE 디렉토리를 생성하기 위해 프로젝트 root 경로에 postinstall 스크립트 설정

import fse from 'fs-extra';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); 

fse.emptyDirSync(path.join(__dirname, 'public', 'tinymce'));
fse.copySync(
  path.join(__dirname, 'node_modules', 'tinymce'),
  path.join(__dirname, 'public', 'tinymce'),
  { overwrite: true }
);

console.log('✅ TinyMCE files copied successfully!');

ES 모듈을 사용한다면 이 코드를 참고하는 걸 추천한다.
공식문서의 예시를 따랐는데, CI/CD 환경에서 import.meta.dirname을 사용하는 빌드 스크립트가 실패하는 현상이 발생했다.
Node.js에서 ES Module(ESM)을 사용할 때는 현재 디렉토리 경로를 얻기 위해 import.meta.url을 사용해야 한다.

3. package.json에 postinstall 스크립트와 .gitignore에 /public/tinymce 디렉토리를 추가한다.

yarn install 실행 시 자동으로 postinstall 실행된다.

// package.json

{
  "scripts": {
    "postinstall": "node ./postinstall.mjs"
  }
}
// .gitignore

/public/tinymce/

4. 터미널에서 yarn postinstall 실행

5. 에디터 컴포넌트 생성 및 적용

공식 문서 - React 기반 통합 기술 문서 를 참고하면 에디터 컴포넌트 props를 사용하기 수월할 것이다.

[내가 수정 및 커스텀 한 목록]

  1. 라이선스키 GPL 사용 (대소문자 구분 ❌)
    공식문서에 관련 자료가 있으니 본인 상황에 맞게 사용하면 된다.
    공식 문서 - 라이선스키

  2. initialValue 대신 value, onEditorChange 사용
    에디터에 내용을 작성하거나 엔터 입력했을 때 커서가 제대로 동작하지 않아서 controlled-component 방식을 사용했다.
    하지만 TinyMCE는 uncontrolled-component 기반으로 설계되었기 때문에 나중에 리팩토링 해도 좋을 것 같다.
    공식 문서 - uncontrolled-component

  3. init props에 사용할 menubar 추가
    다양한 기능을 사용하고자 menubar: false 대신 원하는 메뉴바 탭 이름을 작성했다.
    메뉴바 탭 이름 클릭시 드롭다운으로 나오는 메뉴들 또한 커스텀이 가능하다.
    메뉴와 툴바에서 중복될 필요 없는 부분도 제거했다.

  4. 다중 이미지 업로드 핸들러
    기본 이미지 업로드 기능은 한 개의 이미지 파일만 업로드 가능한데
    여러 이미지를 업로드 하기 위해 handleMultipleImages 함수를 구현했다.

    그냥 이미지를 첨부하면 base64로 인코딩 돼 엄~~~청 긴 문자열로 반환된다.
    이 문자열을 서버에 저장하면 DB 용량을 많이 차지할 뿐만 아니라,
    페이지 로딩 시 불필요하게 긴 문자열을 파싱해야 해서 성능에도 좋지 않다.

    이 함수는
    파일 정보가 담긴 formData를 서버에 보내면,
    서버에서 AWS S3에 이미지를 저장하고 생성된 URL을 반환해 준다.
    이 반환된 url로 이미지 태그를 생성해서 에디터에 추가한다.

init props에 setup 키워드를 사용해 다중 이미지 업로드, 드래그 앤 드랍 이미지 업로드 기능을 커스텀해줬다.

공식 문서 예시를 참고해 커스텀 한 코드이다.

import { useRef } from 'react';

declare global {
  interface Window {
    tinymce: any;
  }
}
import { Editor } from '@tinymce/tinymce-react';
import { Editor as TinyMCEEditor } from 'tinymce';
import ProductAPI from '@/apis/product';

interface Props {
  value: string;
  onChange: (value: string) => void;
}

export default function WebEditor({ value, onChange }: Props) {
  const editorRef = useRef<TinyMCEEditor | null>(null);

    // 다중 이미지 업로드 핸들러
  const handleMultipleImages = async (files: FileList) => {
    try {
      const formData = new FormData();

      Array.from(files).forEach((file, index) => {
        formData.append(`descriptionImages[${index}]`, file); // req 객체에맞게 formData 작성
      });

   // 서버에 이미지 전송하고, 반환된 S3 URL 배열을 통해 이미지 태그 생성
      const { data, status } = await /*API 요청 경로*/; 

      if (status === 200 && editorRef.current) {
        const imageHtml = data
          .map((url: string) => `<img src="${url}" alt="uploaded image" />`)
          .join('<br />');

        editorRef.current.insertContent(imageHtml);
      }
    } catch (error) {
      console.error('Image upload failed:', error);
    }
  };

  return (
    <>
      <Editor
        tinymceScriptSrc="/tinymce/tinymce.min.js"
        licenseKey="gpl"
        onInit={(_evt, editor) => (editorRef.current = editor)}
        value={value ?? ''}
        onEditorChange={(content) => onChange(content)}
        init={{
          height: 500,
          menubar: 'file edit view insert format tools help', // except: table
          menu: {
            file: {
              title: 'File',
              items: 'newdocument restoredraft | preview',
            },
            edit: {
              title: 'Edit',
              items: 'undo redo | cut copy paste | selectall | searchreplace',
            },
            view: {
              title: 'View',
              items:
                'code | visualaid visualchars visualblocks | spellchecker | preview fullscreen',
            },
            insert: {
              title: 'Insert',
              items:
                'image link addcomment pageembed codesample inserttable | math | charmap emoticons hr | pagebreak nonbreaking anchor | insertdatetime',
            },
            format: {
              title: 'Format',
              items:
                'bold italic underline strikethrough superscript subscript codeformat | styles blocks fontfamily fontsize align lineheight | forecolor backcolor | language | removeformat',
            },
            tools: {
              title: 'Tools',
              items:
                'spellchecker spellcheckerlanguage | a11ycheck code wordcount',
            },
            help: { title: 'Help', items: 'help' },
          },
          plugins: [
            'advlist',
            'autolink',
            'lists',
            'link',
            'image',
            'charmap',
            'anchor',
            'searchreplace',
            'visualblocks',
            'code',
            'fullscreen',
            'insertdatetime',
            'preview',
            'help',
            'wordcount',
          ],
          toolbar:
            'undo redo | blocks | ' +
            'bold italic forecolor removeformat | alignleft aligncenter ' +
            'alignright alignjustify | bullist numlist outdent indent | ' +
            'multipleimages  |  | preview |help',
          content_style:
            'body { font-family:Helvetica,Arial,sans-serif; font-size:14px }',
          setup: (editor) => {
            // `image` 아이콘과 `multipleimages` 이름으로 다중 이미지 업로드 기능 커스텀
            editor.ui.registry.addButton('multipleimages', {
              icon: 'image',
              onAction: () => {
                const input = document.createElement('input');
                input.setAttribute('type', 'file');
                input.setAttribute('multiple', 'true');
                input.setAttribute('accept', 'image/*');

                input.onchange = async (e) => {
                  const files = (e.target as HTMLInputElement).files;
                  if (files && files.length > 0) {
                    await handleMultipleImages(files);
                  }
                };

                input.click();
              },
            });
            // 드래그 앤 드랍 다중 이미지 업로드
            editor.on('drop', async (e) => {
              const dataTransfer = e.dataTransfer;
              if (dataTransfer && dataTransfer.files.length > 0) {
                e.preventDefault();

                // 이미지 파일만 필터링
                const imageFiles = Array.from(dataTransfer.files).filter(
                  (file) => file.type.startsWith('image/')
                );

                if (imageFiles.length > 0) {
                  await handleMultipleImages(
                    Object.assign(imageFiles, {
                      item: (i: number) => imageFiles[i],
                    })
                  );
                }
              }
            });
          },
        }}
      />
    </>
  );
}

이렇게 하면 에디터가 생성이 된다!

profile
틀리더라도 🌸🌈🌷예쁘게 지적해주세요💕❣️
post-custom-banner

0개의 댓글