상세 컨텐츠

본문 제목

React(리액트) 티키타카 29 (CkEditor)

React

by e7e 2025. 3. 22. 12:11

본문

 CKEditor5가 좋은 기능은 거의 다 유료화되면서 참으로 쓸만한 게 잘 없당.

 그래도 무료로 쓸수 있는 만큼 써보장.

 

첨부터 다 설정하기에는 플러그인이 너무 마나 AI가 아닌 사람으로써는 무리당.

아래 builder 링크에 가서 고르는 수고가 그나마 조금 나을거시당.

https://ckeditor.com/ckeditor-5/builder/

 

난 Preset  Classic Editor를 선택했는뎅,  Premium이 포함되었다 하여 

두번째 Features 스텝에서  일일이  골라빼보는 재미 (Productivity 와 File Management)

를 하고, 세번째 Toolbar에서도 일단 그냥 재미로 암거나 ON 시키고, 마지막

installation 스텝에서  아래 처럼 선택 아니하지 아니 하지 아니 할 수밖에 없었다.

 

그럼 묻지마  띠작이란 걸 또 해보장.

1. 이름 영어로 아무렇게나  어딘가 나중에도 찾기 쉬운 곳에 새 폴더를 만들공
2. vscode로 해당 폴더를 연 후, 터미널 열공 
3. npx create-vite@latest .                                     명령으로 필요한 파일들 현재 폴더에 다운로드
4. npm i                                                                    명령으로 package.json 파일에 기술된 모듈들 설치
5. 필요 없는 파일들(App.css 등등..)  정리하고 
6. npm i  ckeditor5 @ckeditor/ckeditor5-react     명령으로 ckeditor5 무료버젼 모듈 설치
7. npm run dev 
8. 영문자 O 누르고 엔터  눌러 브라우져 열기

 

아니 주는 코드가 눈을 마구 마구 뱅뱅 돌게 한당. 먼가 하기 시로진당.  

 

제자리 뱅뱅 눈에 브레이크를 걸고, 팽개친 브레인 핸들을 다시 차분히 잡고

정리 모드로 서서히 진입장벽을 너머 가서 휴식을 취하는 모습을 상상하장

 

그냥 복사/ 붙여넣기 하장.  신경 쓰는게 고롭당. 

index.css

@import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,400;0,700;1,400;1,700&display=swap');

@media print {
	body {
		margin: 0 !important;
	}
}

.main-container {
	font-family: 'Lato';
	width: fit-content;
	margin-left: auto;
	margin-right: auto;
}

.ck-content {
	font-family: 'Lato';
	line-height: 1.6;
	word-break: break-word;
}

.editor-container_classic-editor .editor-container__editor {
	min-width: 795px;
	max-width: 795px;
}

 

main.jsx

import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
    <App />
)

 

설정 파일은 추악하게 길고 기니, 따로 빼서 눈에 까시를 빼도록 하여랑.

editorConf.js

import {
  ClassicEditor,
  AutoImage,
  AutoLink,
  Autosave,
  BalloonToolbar,
  Bold,
  Essentials,
  GeneralHtmlSupport,
  ImageBlock,
  ImageCaption,
  ImageEditing,
  ImageInline,
  ImageInsert,
  ImageInsertViaUrl,
  ImageResize,
  ImageStyle,
  ImageTextAlternative,
  ImageToolbar,
  ImageUpload,
  ImageUtils,
  Italic,
  Link,
  LinkImage,
  List,
  ListProperties,
  Paragraph,
  PasteFromOffice,
  SimpleUploadAdapter,
  SourceEditing,
  Table,
  TableCaption,
  TableCellProperties,
  TableColumnResize,
  TableProperties,
  TableToolbar,
} from "ckeditor5";

import translations from "ckeditor5/translations/ko.js";

const LICENSE_KEY = "GPL"; // or <YOUR_LICENSE_KEY>.

// upload 설정만 일부러 밖으로 빼깅
const simpleUpload = {
  /*
    uploadUrl: 'http://seini.co.kr',
    
    withCredentials: true,
    headers: {
        'X-CSRF-TOKEN': 'CSRF-Token',
        Authorization: 'Bearer <JSON Web Token>'
    }
    */
};

const editorConf = {
  editorConfig: {
    toolbar: {
      items: [
        "sourceEditing",
        "|",
        "bold",
        "italic",
        "|",
        "link",
        "insertImage",
        "insertImageViaUrl",
        "insertTable",
        "|",
        "bulletedList",
        "numberedList",
      ],
      shouldNotGroupWhenFull: false,
    },
    plugins: [
      AutoImage,
      AutoLink,
      Autosave,
      BalloonToolbar,
      Bold,
      Essentials,
      GeneralHtmlSupport,
      ImageBlock,
      ImageCaption,
      ImageEditing,
      ImageInline,
      ImageInsert,
      ImageInsertViaUrl,
      ImageResize,
      ImageStyle,
      ImageTextAlternative,
      ImageToolbar,
      ImageUpload,
      ImageUtils,
      Italic,
      Link,
      LinkImage,
      List,
      ListProperties,
      Paragraph,
      PasteFromOffice,
      SimpleUploadAdapter,
      SourceEditing,
      Table,
      TableCaption,
      TableCellProperties,
      TableColumnResize,
      TableProperties,
      TableToolbar,
    ],
    balloonToolbar: [
      "bold",
      "italic",
      "|",
      "link",
      "insertImage",
      "|",
      "bulletedList",
      "numberedList",
    ],
    htmlSupport: {
      allow: [
        {
          name: /^.*$/,
          styles: true,
          attributes: true,
          classes: true,
        },
      ],
    },
    image: {
      toolbar: [
        "toggleImageCaption",
        "imageTextAlternative",
        "|",
        "imageStyle:inline",
        "imageStyle:wrapText",
        "imageStyle:breakText",
        "|",
        "resizeImage",
      ],
    },
    /*
        initialData:
            `<h2>🏠호산나 공동체  CKEditor 5! 셋업 추카 🎉</h2>
             <h2> 🧑‍💻 음 머랄까 데이터를 다뤄야겠네요</h2>`,
        */
    language: "ko",
    licenseKey: LICENSE_KEY,
    link: {
      addTargetToExternalLinks: true,
      defaultProtocol: "https://",
      decorators: {
        toggleDownloadable: {
          mode: "manual",
          label: "Downloadable",
          attributes: {
            download: "file",
          },
        },
      },
    },
    list: {
      properties: {
        styles: true,
        startIndex: true,
        reversed: true,
      },
    },
    menuBar: {
      isVisible: true,
    },
    placeholder: "Type or paste your content here!",
    table: {
      contentToolbar: [
        "tableColumn",
        "tableRow",
        "mergeTableCells",
        "tableProperties",
        "tableCellProperties",
      ],
    },
    simpleUpload,
    translations: [translations],
  },
};

export default editorConf;

 

요거이 본질이당.  도착이당. 복사/붙여넣기 해서 StepFolding 코드로 사용하장.

JunyEditor.jsx

import { useEffect, useMemo, useRef, useState } from "react";
import editorConf from "./editorConf";
import { ClassicEditor } from "ckeditor5";
import { CKEditor } from "@ckeditor/ckeditor5-react";
import "ckeditor5/ckeditor5.css";

const initData = `
    <figure class="image image_resized image-style-align-left" style="width:25%;">
     <img style="aspect-ratio:800/738;border-radius:50%;" src="https://i.imgur.com/cmxVh4x.gif" width="800" height="738">
    </figure>
    <h2>🏠호산나 공동체 CKEditor 5! 셋업 추카 🎉</h2>
    <h2> 🧑‍💻 음 머랄까 데이터를 다뤄야겠네요</h2>
`;
function JunyEditor() {
  const editorContainerRef = useRef(null);
  const editorRef = useRef(null);
  const [isLayoutReady, setIsLayoutReady] = useState(false);
  const [content, setContent] = useState(initData);

  const { editorConfig } = useMemo(() => {
    if (!isLayoutReady) {
      return {};
    }
    return editorConf;
  }, [isLayoutReady]);

  useEffect(() => {
    setIsLayoutReady(true);
    console.log("체에킁:");

    return () => setIsLayoutReady(false);
  }, []);

  const chgContent = (e) => {
    console.log("체킁1: ", e);
    console.log(editorRef.current.getData());
    //setContent(editorRef.current.getData() + editorRef.current.getData());
    setContent("");
  };

  const e7eChg = (e) => {
    console.log("체킁2: ", e);
    setContent(editorRef.current.getData());
  };

  return (
    <div className="main-container">
      <div
        className="editor-container editor-container_classic-editor"
        ref={editorContainerRef}
      >
        <div className="editor-container__editor">
          <div ref={editorRef}>
            {editorConfig && (
              <CKEditor
                editor={ClassicEditor}
                config={editorConfig}
                data={content}
                onReady={(editor) => (editorRef.current = editor)}
                onChange={e7eChg}
              />
            )}
          </div>
        </div>
      </div>
      <div style={{ height: 200, textAlign: "center", margin: 0 }}>
        <button onClick={chgContent}>지우깅</button>
      </div>
      <div
        dangerouslySetInnerHTML={{ __html: content }}
        style={{ marginTop: "-180px" }}
      ></div>
    </div>
  );
}

export default JunyEditor;

하나 주의 깊게 볼 부분은 dangerouslySetInnerHTML={{ __html: content }} 

XSS공격에 취약하영  html 문자열을 직접 넣지 않게 되어 있는데 위처럼 하면

HTML문자열을 HTML로 넣을 수 있당. 그냥 참고 하장.

 

눈으로 봐야 하닝. 누네 보이게 만들장.

App.jsx

import JunyEditor from "./JunyEditor";

export default function App() {
  return (
    <>
      <h1 style={{textAlign:"center",color:"blueviolet"}}>이거시 CKEditor5</h1>
      <JunyEditor />
    </>
  );
}

 

결과는 이러하다.

 

보통 스마트 에디터를 셋업하면, 이미지 파일업로드 부분을 해결해야 할꺼당.

 

공짜인 simpleUpload를 설정해야 한당.~~

아!~~ 백엔드 코드가 필요해졌당.~~

 

일단 uploadUrl에 값을 주고, 그 값대로 스프링 백엔드를 구성하장.

simpleUpload는 파일명(정확히는 FormData에 넣을 때 key값)은 고정으로

upload로 보내고, 돌려받는 값은 {url:"값"} 의 형태를 취해야 한당. 그렇단당.

// upload 설정만 일부러 밖으로 빼깅
const simpleUpload = {
  uploadUrl: "http://localhost:9004/ckeditor/imgup",

  /*
    withCredentials: true,
    headers: {
        'X-CSRF-TOKEN': 'CSRF-Token',
        Authorization: 'Bearer <JSON Web Token>'
    }
    */
};

 

나의 경우 d:에 pupload 폴더를 먼저 만들었고, 여기에 /wupload라는 웹경로를 맵핑했당.

UpDownConfig.java

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Configuration
public class UpDownConfig  implements WebMvcConfigurer{
 
	@Override
	public void addResourceHandlers(ResourceHandlerRegistry registry) {
		log.info("요기가 실행되었는지 check?");
		registry.addResourceHandler("/wupload/**")             // 웹 접근 경로 
		        .addResourceLocations("file:///d:/pupload/");  // 서버내 실제 경로
	}
 
}

 

이제 테스트용 컨트롤러를 맹글장.

SimpleController.java

import java.io.File;
import java.util.HashMap;
import java.util.Map;

import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import lombok.extern.slf4j.Slf4j;

@CrossOrigin("*")
@Slf4j
@RestController
@RequestMapping("/ckeditor")
public class SimpleController {

	@PostMapping("/imgup")
	public Map<String, String> imgUpload(MultipartFile upload) throws Exception {
		
		String fname = upload.getOriginalFilename();
		log.info("체킁 디버깅: {}",fname);
		
		// 저장
		String saveDir = "d:/pupload/";
		upload.transferTo(new File(saveDir + fname ));
		
		String webPath = "http://localhost:9004/wupload/" + fname;
		Map<String, String> retMap = new HashMap<>();
		retMap.put("url",webPath);
		
		return retMap;
	}
	
}

스프링 톰캣을 구동시키장.~~ 난 포트번호 9004당.

 

React에서 테스트하닝 아래처럼  잘되고야 만당. ~~

이제 이것을 베이스로 확장해 나가는 재미를 느낀다면 어찌 좋지 아니 하지 아니 하겠는강!

 

BUT 이대로 애매모호 문제를 잉태한 상태로 놓아두기에는 가슴에 사는 양심이

눈물바다로 배를 항해 하는 배 위를  후회의 눈물 파도로 침몰시킬 수 있다.

양심은 삶을 조금 피곤하게 하지만, 침몰보다는 낫당.

 

이미지 업로드에 사실 토큰값을 안 보낸들 또 누가 무어라 하리

이미지 업로드 URL을 1개만 고정으로 한들 또 누가 무어라 하리

그렇지만 그렇지 않은 상황이 분명 발생할 가능성이 확실히 높당.

 

이제 CKEditor 빌더에서 제공받아 단순 분리만 했던 JunyEditor.jsx 소스를

조금 깔끄미스럽게 정리하여  보장. 핵심은 CKEditor의 속성 id와 onReady 이벤트당

id값이 바뀌어야  reload가 되고, onReady 이벤트에서 이걸 확인할 수있당.

[사실 이거 때문에 시간을 많이 빼끼고 말았당. 사람이든 컴퓨터든 오래되면 느리당]

 

JunyEditor.jsx

import { CKEditor } from "@ckeditor/ckeditor5-react";
import { ClassicEditor } from "ckeditor5";
import "ckeditor5/ckeditor5.css";
import { useEffect, useRef, useState } from "react";
import editorConf from "./editorConf";
 
const initData = `
    <h2>🏠호산나 공동체 CKEditor 5! 셋업 추카 🎉</h2>
    <h2> 🧑‍💻 음 머랄까 데이터를 다뤄야겠네요</h2>
    <figure class="image" style="width:25%;">
     <img style="aspect-ratio:800/738;border-radius:50%;" src="https://i.imgur.com/cmxVh4x.gif" width="800" height="738">
    </figure>
`;

function JunyEditor() {
  const editorContainerRef = useRef(null);
  const editorRef = useRef(null);
  const [content, setContent] = useState(initData);
  const [editorConfig,setEditorConfig] = useState(editorConf.editorConfig);
  const [edId,setEdId]= useState(1);

  //console.log("체킁1",editorConfig);
  
  const rmContent = () => {
    console.log("현내용 확인:",editorRef.current.getData());
    setContent(""); //editorRef.current.setData("") 효과
  };
 
  // SimpleUpload 설정 바꾸깅
  const setConf = ()=>{

    const simpleUpload = {
      uploadUrl: 'http://e7e.co.kr',
      withCredentials: true,
      headers: {
          'X-CSRF-TOKEN': 'e7e csrf',
          Authorization: 'Bearer JWT'
      }
    }
    setEditorConfig({...editorConfig,simpleUpload})
    setEdId(preval => preval+1)
  }

  // 디버깅에 중요
  const e7eReady = (editor)=>{
    console.log("이게 보여야 CKEditor 리로딩(Refresh)");
    editorRef.current = editor
  }

  // 그냥 덤에 덤으로 덤앤더머
  const e7eChg = () => {
    setContent(editorRef.current.getData());
  };

  useEffect(()=>{
  },[])

  return (
    <div className="main-container">
      <div
        className="editor-container editor-container_classic-editor"
        ref={editorContainerRef}
      >
        <div className="editor-container__editor">
          <div ref={editorRef}>
              <CKEditor
                id={edId}       // config reload를 위해 필요
                editor={ClassicEditor}
                config={editorConfig}
                data={content}
                onReady={e7eReady}
                onChange={e7eChg}
              />
          </div>
        </div>
      </div>
      <div style={{ height: 200, width:'60%', margin:"5px auto"}}>
        <button onClick={rmContent}>내용지우깅</button>
        <button onClick={setConf}>Config 세팅</button>
      </div>
      <div
        dangerouslySetInnerHTML={{ __html: content }}
        style={{ marginTop: "-180px" }}
      ></div>
    </div>
  );
}
 
export default JunyEditor;

확인해 보면 잘 될꺼이당.

 

서버단에서도 HttpServletRequest 객체의 Header를 확인해 보면

바뀌는 값이 네트워크 담을 넘어 옴을 확인할 수 있을 거시당.

 

안농!~~ (^-^)


 

아이들은 비가 오면 더 모미 근질거려 

 

참지 못하고 비를  맞고 맞으며 

 

감기 장작으로 지핀 열이 펄펄 끓을때까지

 

그렇게 그렇게 고집불통으로 놀아된다.

 

어른들은 말했다.  이렇게 

 

비 쏟아지는  날 궂이  이렇게 놀고플까?

 

날구지의 탄생이다.

 

날구지 할 만큼 넘치는 에너지의 흐름

 

영어에선 그것을 HOT 하다 하였다.

 

당신은 지금 HOT한가 아님 HOT하게 되고픈가?

 

그렇다면 분명 당신은 배 고프당!,

 

HOT 하려면 에너지 효율 등급이 나락이당.!

 

 

https://www.youtube.com/watch?v=2VAKcz9W4WM

관련글 더보기