React
ckeditor 생성은 https://ckeditor.com/ckeditor-5/builder/ 여기서 할 수 있다.
여기서 에디터의 종류나 사용할 옵션을 모두 선택할 수 있다.

나는 무료버전인 Classic Editor를 선택했고, Next를 눌러준다.

그 다음에는 옵션을 선택할 수 있다.
필요한 옵션을 선택하고, Next를 눌러준다.
참고로 옵션중에는 유료 옵션들도 있으니 무료버전을 사용하고 싶다면 무료 옵션만 선택해야 한다.

다음은 툴바 스타일을 선택할 수 있는 화면이 나온다.

마지막으로 언어 선택과, 설치할 수 있는 방법을 선택할 수 있는 옵션이 나온다.
나는 리액트 환경에서 설치할 것이기 때문에 리액트를 선택했고, npm으로 설치하는 방법을 선택했다.

Copy Code Snippets를 선택하면 npm 설치 방법부터
코드까지 모두 제공해준다.
저 코드를 복사해서 내 프로젝트에 넣어주면 된다.
나는 CustomEditor라는 별도의 컴포넌트를 만들어서 거기에 에디터를 추가해주었다.
// CustomEditor.tsx
const LICENSE_KEY = 'GPL';
const editorConfig = {
plugins: [
Alignment, AutoImage, Autosave, BlockQuote, Bold, Emoji,
Essentials, FontBackgroundColor, FontColor, FontSize,
Heading, Highlight, HorizontalLine, ImageBlock, ImageCaption,
],
toolbar: {
items: [
'undo', 'redo', '|', 'heading', '|', 'fontSize',
'fontColor', 'fontBackgroundColor', '|', 'bold', 'italic',
],
shouldNotGroupWhenFull: true,
},
......
extraPlugins: [FileUploaderPlugin],
......
licenseKey: LICENSE_KEY,
};editorConfig에 내가 선택한 옵션이 들어가 있는 것을 확인할 수 있다.
에디터 툴바 옵션의 순서를 변경하고 싶으면, toolbar의 items에 있는 옵션들의 순서를 변경하면 된다.
licenseKey의 경우 무료 사용 버전은 'GPL'을 넣어주면 된다.
그리고 서버로 이미지를 업로드해야 하기 때문에 파일 업로더는 커스텀을 해야 했다.
// FileUpload.ts
'use client';
import axios from 'axios';
export default function FileUploaderPlugin(editor: any) {
editor.plugins.get('FileRepository').createUploadAdapter = (loader: any) => {
return uploadAdapter(loader);
};
function uploadAdapter(loader: any) {
const uploadImage = async (formData: any) => {
const { data } = await axios.post(`/api/common/image/upload`, formData);
return data;
};
return {
upload: async () => {
try {
const file: File = await loader.file;
// 파일 형식 검증
if (!file.type.startsWith('image/')) {
throw new Error('이미지 파일만 업로드 가능합니다.');
}
// 파일 크기 제한
if (file.size > 5 * 1024 * 1024) {
throw new Error('파일 크기는 5MB를 초과할 수 없습니다.');
}
const body = new FormData();
body.append('file', file);
const res = await uploadImage(body);
if (!res.id) {
throw new Error('서버 응답이 올바르지 않습니다.');
}
return {
default: `${process.env.NEXT_PUBLIC_API_ENDPOINT}/image/${res.id}`,
};
} catch (error: any) {
const message = error.response?.data?.message || error.message || '업로드 중 오류가 발생했습니다.';
throw new Error(message);
}
},
};
}
}ckeditor의 FileRepository 플러그인에 uploadAdaptor라는 커스텀 파일 업로더를 등록해준다.
그리고 ckeditor가 제공하는 upload 메소드에 함수를 구현한다.
서버에서는 file이라고 보낼 것을 요구하고 있기 때문에,
빈 FormData 객체에 file 이라는 이름으로 파일 객체를 추가했다.
통신이 성공하면 api는 response로 id를 보내줘서,
이미지 주소에 id를 포함해서 ckeditor가 요구하는 형식인 { default: "URL" } 형식으로 이미지 주소를 넣어준다.
// CustomEditor.tsx
'use client';
import { useMemo } from 'react';
import { CKEditor } from '@ckeditor/ckeditor5-react';
import {
ClassicEditor,
......
Underline,
} from 'ckeditor5';
import FileUploaderPlugin from './adapters/FileUpload';
import translations from 'ckeditor5/translations/ko.js';
import 'ckeditor5/ckeditor5.css';
import * as S from './styled';
const LICENSE_KEY = 'GPL';
type Props = {
value: string;
onChange: (editor: any) => void;
isError?: boolean;
};
const CustomEditor = ({ value, onChange, isError }: Props) => {
const { editorConfig } = useMemo(() => {
return {
editorConfig: {
toolbar: {
items: [
'undo',
....
'indent',
],
shouldNotGroupWhenFull: true,
},
extraPlugins: [FileUploaderPlugin],
plugins: [
Alignment,
.....
Underline,
],
fontSize: {
options: [10, 12, 14, 'default', 18, 20, 22],
supportAllValues: true,
},
heading: {
options: [
{
model: 'paragraph' as const,
title: 'Paragraph',
class: 'ck-heading_paragraph',
},
{
model: 'heading1' as const,
view: 'h1',
title: 'Heading 1',
class: 'ck-heading_heading1',
},
],
},
image: {
toolbar: [
'toggleImageCaption',
'imageTextAlternative',
'|',
'imageStyle:inline',
'imageStyle:wrapText',
'imageStyle:breakText',
'|',
'resizeImage',
],
},
initialData: value || '',
language: 'ko',
licenseKey: LICENSE_KEY,
link: {
addTargetToExternalLinks: true,
defaultProtocol: 'https://',
decorators: {
toggleDownloadable: {
mode: 'manual' as const,
label: 'Downloadable',
attributes: {
download: 'file',
},
},
},
},
placeholder: '내용을 입력해주세요.',
translations: [translations],
},
};
}, [value]);
const handleEditorReady = (editor: any) => {
try {
if (value) {
editor.setData(value);
}
} catch (error) {
console.error('Error setting editor data:', error);
}
};
const handleEditorChange = (event: any, editor: any) => {
try {
const data = editor.getData();
onChange(data);
} catch (error) {
console.error('Error getting editor data:', error);
}
};
return (
<>
<S.EditorContainer $isError={isError}>
<CKEditor
editor={ClassicEditor}
config={editorConfig}
data={value || ''}
onChange={handleEditorChange}
onReady={handleEditorReady}
/>
</S.EditorContainer>
{isError && <p style={{ color: 'red' }}>내용을 입력해주세요.</p>}
</>
);
};
export default CustomEditor;
이렇게 해서 에디터 구현 완료!