프로젝트의 개발 환경
- Next.js v14
- Typescript v5
- react v18
- ES 모듈
TinyMCE를 적용하는 방법은
이렇게 세 가지 방법이 있고, 나는 패키지 매니저
를 사용했다.
TinyMCE
의 경우 에디터 사용하는 것 보단 나중에 환경 셋팅하는 데 더 시간이 많이 들었다.
참고할 공식 문서의 자료가 여기저기 있는 느낌?이라 React
기반 기술문서를 제대로 활용하지 못했다.
그러다 보니 js 기반으로 된 내용을 typescript에 맞게 환경을 수정하는 과정이 필요했다.
tinymce
, @tinymce/tinymce-react
, fs-extra
패키지 설치yarn add tinymce @tinymce/tinymce-react fs-extra
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
을 사용해야 한다.
postinstall
스크립트와 .gitignore에 /public/tinymce 디렉토리를 추가한다.
yarn install
실행 시 자동으로postinstall
실행된다.// package.json { "scripts": { "postinstall": "node ./postinstall.mjs" } }
// .gitignore /public/tinymce/
공식 문서 - React 기반 통합 기술 문서 를 참고하면 에디터 컴포넌트 props를 사용하기 수월할 것이다.
라이선스키 GPL
사용 (대소문자 구분 ❌)
공식문서에 관련 자료가 있으니 본인 상황에 맞게 사용하면 된다.
공식 문서 - 라이선스키
initialValue
대신 value
, onEditorChange
사용
에디터에 내용을 작성하거나 엔터 입력했을 때 커서가 제대로 동작하지 않아서 controlled-component 방식을 사용했다.
하지만 TinyMCE는 uncontrolled-component 기반으로 설계되었기 때문에 나중에 리팩토링 해도 좋을 것 같다.
공식 문서 - uncontrolled-component
init props에 사용할 menubar 추가
다양한 기능을 사용하고자 menubar: false
대신 원하는 메뉴바 탭 이름을 작성했다.
메뉴바 탭 이름 클릭시 드롭다운으로 나오는 메뉴들 또한 커스텀이 가능하다.
메뉴와 툴바에서 중복될 필요 없는 부분도 제거했다.
다중 이미지 업로드 핸들러
기본 이미지 업로드 기능은 한 개의 이미지 파일만 업로드 가능한데
여러 이미지를 업로드 하기 위해 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], }) ); } } }); }, }} /> </> ); }
이렇게 하면 에디터가 생성이 된다!