애플리케이션 개발 중에 에디터가 필요했다.
CKEditor를 사용한 이유는 크게 없고 다들 CKEditor를 쓰길래..(찾아보기 귀찮아)
기본 Setting
1. CKEditor랑 에디터 타입 설치
나는 DecoupledEditor(=Document) 를 사용했다.
yarn add @ckeditor/ckeditor5-react @ckeditor/ckeditor5-build-decoupled-document --save --dev
첫번째 난관 봉착,,
에디터 타입(Document)는 typescript를 지원하지만 CKEditor는 typescript를 지원하지 않기 때문에 index.d.ts 에서 모듈선언을 해줘야한다.
@types/index.d.ts
declare module '@ckeditor/ckeditor5-react' {
import DecoupledEditor from '@ckeditor/ckeditor5-build-decoupled-document';
import Event from '@ckeditor/ckeditor5-utils/src/eventinfo'
import { CKEditor } from '@ckeditor/ckeditor5-react';
import { EditorConfig } from '@ckeditor/ckeditor5-core/src/editor/editorconfig'
import * as React from 'react';
const CKEditor: React.FunctionComponent<{
disabled?: boolean;
editor: typeof DecoupledEditor;
data?: string;
id?: string;
config?: EditorConfig;
onReady?: (editor: CKEditor) => void;
onChange?: (event: Event, editor: CKEditor) => void;
onBlur?: (event: Event, editor: CKEditor) => void;
onFocus?: (event: Event, editor: CKEditor) => void;
onError?: (event: Event, editor: CKEditor) => void;
}>
export { CKEditor };
}
2. CKEditor 로드
Classsic 버전은은 툴바가 자동으로 들어가는데, Document 버전은 툴바를 수동으로 넣어줘야 된다.(onReady 이벤트 부분 참고)
대신 Classic 버전에선 폰트 스타일을 추가 할 수 없었는데 Document 버전에선 추가할 수 있었다. (내가 못하는 거 일수도..)
import React from 'react';
import { CKEditor } from '@ckeditor/ckeditor5-react';
import DecoupledEditor from '@ckeditor/ckeditor5-build-decoupled-document';
<CKEditor
id={'editor'}
onReady={editor => {
console.log('Editor is ready to use!', editor);
// Insert the toolbar before the editable area.
editor.ui.getEditableElement().parentElement.insertBefore(
editor.ui.view.toolbar.element,
editor.ui.getEditableElement()
);
}}
editor={DecoupledEditor}
data="<p>Hello from CKEditor 5!</p>"
config={{
toolbar: ['heading', '|', 'fontFamily', 'fontSize', 'fontColor', 'fontBackgroundcolor', '|', 'bold', 'italic', 'underline', 'strikethrough', '|', 'alignment', '|', 'numberedList', 'bulletedList', '|', 'indent', 'outdent', '|', 'blockQuote', 'imageUpload'],
fontFamily: {
options: [
'default',
'Arial, Helvetica, sans-serif',
'Courier New, Courier, monospace',
'Georgia, serif',
'Lucida Sans Unicode, Lucida Grande, sans-serif',
'Tahoma, Geneva, sans-serif',
'Times New Roman, Times, serif',
'Trebuchet MS, Helvetica, sans-serif',
'Verdana, Geneva, sans-serif',
'나눔고딕',
'명조',
'나눔바른고딕',
],
supportAllValues: true,
}
}}
/>
3. Data Export & Import
1) export
11번째 줄과 같이 editor2(전역 변수)에 editor를 복사한다. (참조 복사)
<CKEditor
id={'editor'}
onReady={editor => {
console.log('Editor is ready to use!', editor);
// Insert the toolbar before the editable area.
editor.ui.getEditableElement().parentElement.insertBefore(
editor.ui.view.toolbar.element,
editor.ui.getEditableElement()
);
editor2 = editor;
}}
editor={DecoupledEditor}
data="<p>Hello from CKEditor 5!</p>"
/>
2) import
import 하는 곳에선 disabled값을 true로 줘서 읽기전용을 설정해준다.
data 영역에 아까 복사해두었던 editor2의 getData 함수를 이용해 data를 넣어주면 된다.
<CKEditor
id={'editor'}
disabled={true}
data={editor2.getData()}
editor={DecoupledEditor}
/>
3) 코드 전체
apply 버튼을 누르면 에디터에서 편집된 데이터를 아래 읽기전용으로 노출되도록 테스트 했다.
import React, { useState } from 'react';
import classNames from 'classnames/bind';
import { CKEditor } from '@ckeditor/ckeditor5-react';
import DecoupledEditor from '@ckeditor/ckeditor5-build-decoupled-document';
import styles from './Editor.scss';
const cx = classNames.bind(styles);
let editor2: DecoupledEditor;
const Editor: React.FC = ({
}) => {
const [showEditor, setShowEditor] = useState(false);
const handleClickBtn = () => {
setShowEditor(!showEditor);
}
return (
<div className={cx('editor')}>
<button type="button" onClick={handleClickBtn}>apply</button>
<CKEditor
id={'editor'}
onReady={editor => {
console.log('Editor is ready to use!', editor);
// Insert the toolbar before the editable area.
editor.ui.getEditableElement().parentElement.insertBefore(
editor.ui.view.toolbar.element,
editor.ui.getEditableElement()
);
editor2 = editor;
}}
editor={DecoupledEditor}
data="<p>Hello from CKEditor 5!</p>"
/>
{showEditor && (
<CKEditor
id={'editor'}
disabled={true}
data={editor2.getData()}
editor={DecoupledEditor}
/>
)}
</div>
)
}
export default Editor;
플러그인 사용
이것 때문에 하루를 꼬박 삽질했다..지금은 새벽 3시지만, 내일이면 까먹을 거 같으니깐 정리하고 자야지.
CKEditor은 크게 빌드 버전과 커스텀 버전이 있다.
1. 빌드버전
@ckeditor/ckeditor5-build-decoupled-document
빌드버전은 yarn으로 ckEditor를 설치하면 바로 에디터를 웹에 띄울 수 있다.
빌드버전은 툴바의 구성 정도는 바꿀 수 있으나 플러그인을 통해 커스텀 할 수 없다.
빌드버전과 플러그인을 함께 쓰면 CKEditorError: ckeditor-duplicated-modules 에러가 발생한다.
따라서 플러그인을 함께 쓰려면 커스텀 버전을 사용해야된다.
2. 커스텀버전
@ckeditor/ckeditor5-editor-decoupled/src/decouplededitor
나는 Image Upload 기능을 구현해야됐어서 플러그인 사용이 필수라 커스텀 버전을 사용했다.
하지만 인생이 그렇게 호락호락하지 않다.
아래와 같이 작성하면 Cannot read property 'getAttribute' of null 에러가 발생한다.
import React, { useState } from 'react';
import { CKEditor } from '@ckeditor/ckeditor5-react';
import DecoupledEditor from '@ckeditor/ckeditor5-editor-decoupled/src/decouplededitor';
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
const Editor = () => {
return (
<div>
<CKEditor
onReady={editor => {
console.log('Editor is ready to use!', editor);
// Insert the toolbar before the editable area.
editor.ui.getEditableElement().parentElement.insertBefore(
editor.ui.view.toolbar.element,
editor.ui.getEditableElement()
);
}}
editor={DecoupledEditor}
config={{
plugins: [Bold],
toolbar: ['bold']
}}
/>
</div>
)
}
export default Editor;
Webpack에서 svg 파일을 가져오지 못해서 발생한 에러인데, 문서를 읽어보니 플러그인을 추가하기 위해서는 에디터를 다시 빌드하는 작업이 필요하다고 한다.
플러그인이 추가될 때 마다 에디터를 다시 빌드하는 방법은 두 가지가 있다.
1) CKEditor 의 소스코드를 github 에서 cloning 한 뒤 코드를 고치고 재 빌드 하는 방법
( https://ckeditor.com/docs/ckeditor5/latest/builds/guides/integration/installing-plugins.html )
2) 플러그인을 수정할 때마다 에디터의 재빌드 과정 없이 나의 리액트 어플리케이션 코드와 유기적으로 통합시키는 방법
( https://ckeditor.com/docs/ckeditor5/latest/builds/guides/integration/advanced-setup.html )
처음에는 두 번째 방법으로 진행했었다.
첫 번째 방법은 에디터의 버전을 내가 따로 관리를 해야되고, 두 번째 방법은 webpack만 수정하면 애플리케이션에서 에디터를 직접 관리할 수 있어서 두 번째 방법이 더 좋아보이나..
리서치한 사례들이 모두 create-react-app을 craco로 커스텀한 프로젝트들이여서 내 프로젝트에 적용하기 어려웠음.
왜냐면 나는 scss도 사용하고 svg도 사용하고 url-loader를 사용해서 시키는대로 셋팅을 했는데도 에러가 좀처럼 사라지지 않았음..
그래서 멘탈 나가서 관둘까 하다가 첫 번째 방법을 발견하고 다시 멘탈을 잡음...
무튼 두번째 방법은 아래 URL대로 따라하면 된다고 함
아무튼 나는 첫 번째 방법으로 진행했다.
1) ckeditor5 clone
git clone -b stable https://github.com/ckeditor/ckeditor5
2) ckeditor5/packages/ckeditor5-build-classic로 이동해서 npm install
cd ckeditor5/packages/ckeditor5-build-decoupled-document
npm install
3) src/ckeditor.js에서 필요한 플러그인 install 해서 사용하면 된다.
이미지 업로드
나는 이미지 업로드를 구현하기 위해 ImageUpload, SimpleUploadAdapter 플러그인과 커스텀 업로더 플러그인을 구현해서 사용했다.
해당 작업은 아래 블로그를 참고해서 작업했다..(선생님 그저 빛입니다..적게 일하고 많이 버세요)
https://bloodseeker.tistory.com/3
이미지 업로드는 아래 2개와 같이 plugin을 추가 구현 없이 사용할 수도 있다.
a. Easy Image: image를 CKEditor의 cloud에 upload하게 함(우리 서버가 아닌 cloud에 upload).
b. CKFinder: image/file 모두 upload가능하고 관리까지 가능한 강력한 plugin. (유료)
그리도 두개의 adapter를 제공한다.
a. Base64 adapter: image를 base64-encoded string으로 변환하여 저장. (파일 관리 없이 string을 db에 저장해서 편함)
b. Simple upload adapter: XMLHttpRequest API를 이용하여 file/image를 server에 upload하고 response를 받아서 처리하는 adapter.
나는 Simple upload adapter 방법을 사용했다.
ImageUpload 플러그인으로 사용자가 이미지를 선택하고, Simple Upload Adapter로 image를 서버에 보내고 editor에 이미지를 추가하는 플로우다.
1) uploader 플러그인 구현
import _ from 'lodash';
import { Plugin } from 'ckeditor5/src/core';
import { FileDialogButtonView } from 'ckeditor5/src/upload';
import imageIcon from "@ckeditor/ckeditor5-core/theme/icons/image.svg";
const createImageTypeRegExp = types => {
// Sanitize the MIME type name which may include: "+", "-" or ".".
const regExpSafeNames = types.map(type => type.replace('+', '\\+'));
return new RegExp(`^image\\/(${regExpSafeNames.join('|')})$`);
};
class Uploader extends Plugin {
init() {
const editor = this.editor;
editor.ui.componentFactory.add('insertFileAndImage', locale => {
const view = new FileDialogButtonView(locale);
const imageTypes = editor.config.get('image.type');
const imageTypesRegExp = createImageTypeRegExp(imageTypes);
view.buttonView.set({
label: "Insert image and file",
icon: imageIcon,
tooltip: true,
});
// 사용자가 upload할 file/image를 선택 시 done event가 발생함.
view.on('done', (evt, files) => {
const [imagesToUpload] = _.partition(files, file =>
imageTypesRegExp.test(file.type)
);
if (imagesToUpload.length) {
editor.execute('imageUpload', { file: imagesToUpload });
}
});
return view;
});
}
}
export default Uploader;
2) 구현한 Upload Plugin을 editor에 추가
import DecoupledEditorBase from '@ckeditor/ckeditor5-editor-decoupled/src/decouplededitor';
import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
import Alignment from '@ckeditor/ckeditor5-alignment/src/alignment';
import FontSize from '@ckeditor/ckeditor5-font/src/fontsize';
import FontFamily from '@ckeditor/ckeditor5-font/src/fontfamily';
import FontColor from '@ckeditor/ckeditor5-font/src/fontcolor';
import FontBackgroundColor from '@ckeditor/ckeditor5-font/src/fontbackgroundcolor';
import UploadAdapter from '@ckeditor/ckeditor5-adapter-ckfinder/src/uploadadapter';
import Autoformat from '@ckeditor/ckeditor5-autoformat/src/autoformat';
import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
import Strikethrough from '@ckeditor/ckeditor5-basic-styles/src/strikethrough';
import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline';
import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote';
import CKFinder from '@ckeditor/ckeditor5-ckfinder/src/ckfinder';
import EasyImage from '@ckeditor/ckeditor5-easy-image/src/easyimage';
import Heading from '@ckeditor/ckeditor5-heading/src/heading';
import Image from '@ckeditor/ckeditor5-image/src/image';
import ImageCaption from '@ckeditor/ckeditor5-image/src/imagecaption';
import ImageResize from '@ckeditor/ckeditor5-image/src/imageresize';
import ImageStyle from '@ckeditor/ckeditor5-image/src/imagestyle';
import ImageToolbar from '@ckeditor/ckeditor5-image/src/imagetoolbar';
import ImageUpload from '@ckeditor/ckeditor5-image/src/imageupload';
import Indent from '@ckeditor/ckeditor5-indent/src/indent';
import IndentBlock from '@ckeditor/ckeditor5-indent/src/indentblock';
import Link from '@ckeditor/ckeditor5-link/src/link';
import List from '@ckeditor/ckeditor5-list/src/list';
import ListStyle from '@ckeditor/ckeditor5-list/src/liststyle';
import MediaEmbed from '@ckeditor/ckeditor5-media-embed/src/mediaembed';
import { Paragraph } from 'ckeditor5/src/paragraph';
import PasteFromOffice from '@ckeditor/ckeditor5-paste-from-office/src/pastefromoffice';
import Table from '@ckeditor/ckeditor5-table/src/table';
import TableToolbar from '@ckeditor/ckeditor5-table/src/tabletoolbar';
import { TextTransformation } from 'ckeditor5/src/typing';
import CloudServices from '@ckeditor/ckeditor5-cloud-services/src/cloudservices';
import { SimpleUploadAdapter } from 'ckeditor5/src/upload';
import Uploader from './uploader';
export default class DecoupledEditor extends DecoupledEditorBase { }
// Plugins to include in the build.
DecoupledEditor.builtinPlugins = [
Essentials,
Alignment,
FontSize,
FontFamily,
FontColor,
FontBackgroundColor,
UploadAdapter,
Autoformat,
Bold,
Italic,
Strikethrough,
Underline,
BlockQuote,
CKFinder,
CloudServices,
EasyImage,
Heading,
Image,
ImageCaption,
ImageResize,
ImageStyle,
ImageToolbar,
ImageUpload,
Indent,
IndentBlock,
Link,
List,
ListStyle,
MediaEmbed,
Paragraph,
PasteFromOffice,
Table,
TableToolbar,
TextTransformation,
// 플러그인 추가 코드
Uploader,
SimpleUploadAdapter
];
// Editor configuration.
DecoupledEditor.defaultConfig = {
toolbar: {
items: [
'heading',
'|',
'fontfamily',
'fontsize',
'fontColor',
'fontBackgroundColor',
'|',
'bold',
'italic',
'underline',
'strikethrough',
'|',
'alignment',
'|',
'numberedList',
'bulletedList',
'|',
'outdent',
'indent',
'|',
'link',
'blockquote',
'insertFileAndImage',
'insertTable',
'mediaEmbed',
'|',
'undo',
'redo'
]
},
image: {
resizeUnit: 'px',
toolbar: [
'imageStyle:inline',
'imageStyle:wrapText',
'imageStyle:breakText',
'|',
'toggleImageCaption',
'imageTextAlternative'
],
type: ['jpeg', 'jpg', 'gif', 'png']
},
table: {
contentToolbar: [
'tableColumn',
'tableRow',
'mergeTableCells'
]
},
// 플러그인 추가 코드
simpleUpload: {
uploadUrl: '/uploadFile',
withCredentials: true,
headers: {
'upload-folder': 'Test',
'upload-editor': 'bloodseeker'
}
},
// This value must be kept in sync with the language defined in webpack.config.js.
language: 'en'
};
3) 테스트
서버를 간단하게 만들어서 테스트를 해보았다.
//server.js
const Koa = require('koa');
const serve = require('koa-static');
const Router = require('koa-router');
const koaBody = require('koa-body');
const send = require('koa-send');
const path = require('path');
const { upload_file, _UPLOAD_PATH } = require('./upload_file');
const app = new Koa();
const router = new Router();
router.post('/uploadFile', async (ctx, next) => {
const { file, editor } = await upload_file(ctx);
ctx.body = {
urls: {
default: 'http://localhost:4001/' + file,
editor
}
};
});
app.use(serve('./upload_file'));
app.use(serve(_UPLOAD_PATH));
app.use(koaBody({ multipart: true }))
.use(router.routes())
.use(router.allowedMethods());
app.listen(4001);
console.log('listening on port 4001');
간단하게 설명하면 simpleUploadAdapter로 /uploadFile 로 이미지를 보내면 ctx.body.urls에 이미지 경로를 등록해주면 editor가 해당 경로를 갖는 이미지를 삽입한다.
router.post('/uploadFile', async (ctx, next) => {
const { file, editor } = await upload_file(ctx);
ctx.body = {
urls: {
default: 'http://localhost:4001/' + file,
editor
}
};
});
upload_file 함수는 전달 받은 이미지파일을 로컬에 저장하는 함수이다.
// upload_file.js
const fs = require("fs");
const path = require("path");
const _ = require("lodash");
const md5 = require("md5");
const _UPLOAD_PATH = "./upload_file";
fs.mkdirSync(_UPLOAD_PATH, { recursive: true });
const upload_file = async (ctx) => {
const file = ctx.request.files.upload;
const buf = await fs.promises.readFile(file.path);
const hash = md5(buf);
let uploadFolder = _.get(ctx, ["request", "header", "upload-folder"]);
let editor = _.get(ctx, ["request", "header", "upload-editor"]);
editor = editor.length > 0 ? editor : "none"
uploadFolder = path.join(uploadFolder === "root" ? "" : uploadFolder, editor, hash);
const folderPath = path.join(_UPLOAD_PATH, uploadFolder);
await fs.promises.mkdir(folderPath, { recursive: true });
await fs.promises.writeFile(path.join(folderPath, file.name), buf);
return {
file: path.join(uploadFolder, file.name),
editor: editor
};
};
module.exports = {
upload_file,
_UPLOAD_PATH,
};
4) build
이렇게 완성한 editor를 yarn run build로 build 한 후 build/ckeditor.js를 애플리케이션의 node_modules/@ckeditor5/ckeditor5-build-decoupled-document/build에 overwrite 시키면 된다.
5) 실행
실행하면 커스텀한 에디터가 잘 노출된다.
import React, { useState } from 'react';
import { CKEditor } from '@ckeditor/ckeditor5-react';
import DecoupledEditor from '@ckeditor/ckeditor5-build-decoupled-document';
const Editor = () => {
return (
<div>
<CKEditor
onReady={editor => {
console.log('Editor is ready to use!', editor);
// Insert the toolbar before the editable area.
editor.ui.getEditableElement().parentElement.insertBefore(
editor.ui.view.toolbar.element,
editor.ui.getEditableElement()
);
}}
editor={DecoupledEditor}
/>
</div>
)
}
export default Editor;
참고
https://geniyong.github.io/2019/03/14/CKEditor5-+-React-%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0.html
https://bloodseeker.tistory.com/3
웹폰트 연결
// 작성중..-ㅅ-
'JavaScript > React.js' 카테고리의 다른 글
CRA에서 api server proxy (0) | 2021.12.31 |
---|---|
React에서 GSAP ScrollTrigger 사용법 (0) | 2021.11.02 |
React 에 Google Analytics 적용 (0) | 2021.10.26 |
CRA + Craco + TypeScript (0) | 2020.11.16 |
Express + React 배포 설정 (0) | 2020.07.29 |