2020년 8월 31일

스프링기반 웹 프로그램에서 CKEditor 사용하기 - 이미지 업로드

CKEditor 는 WYSIWYG 편집기로 웹 환경에서 콘텐츠를 손쉽게 작성 및 수정을 가능하게 하는 웹 에디터다. 개발과 테스트 환경은 아래와 같다.

Development
  • Visual Studio Code 1.48.1
  • npm 6.14.5
  • webpack 4.44.1
  • HW : MacBook Pro 2019 
    • macOS Catalina 10.15.6
    • 2.3Ghz 8Core Intel Corei9 
    • 16GB 2667 Mhz DDR4 
Server 
  • Spring 4.3.23.RELEASE 
  • Spring Security 4.2.12.RELEASE
  • Tomcat 9.0.27.0
  • Open JDK 13.0.1 
  • mysql 8.0.17
  • HW : Mac mini Late 2012 
    • macOS Catalina 10.15.6
    •  2.3Ghz 4Core Intel Core i7
    • 16GB 1600 MHz DDR3


라이선스

라이선스를 확인해보면, CKEditor는 GPL, LGPL, MPL 등의 오픈소스 라이선스와 여러 등급의 상용 라이선스로 배포 및 판매되고 있다.

  • CKEditor 4: GPL-2, LGPL-2.1, MPL-1.1, Commercial License (https://github.com/ckeditor/ckeditor-dev)
  • CKEditor 5: GPL-2, Commercial License (https://github.com/ckeditor/ckeditor5)

GPL, LGPL, MPL 은 모두 각각의 라이선스 의무사항만 준수한다면 상업적 이용을 허용하고 있으며, 각 라이선스의 대표적 의무사항은 다음과 같다.
  • GPL: 응용프로그램의 소스코드를 포함 한 모든 소스코드를 수취자(고객)에게 제공. 링크되는 모든 코드를 GPL로 정의
  • LGPL: 정적링크 시 응용프로그램의 목적코드와 LGPL 자체의 소스코드(수정이 있을 경우 수정부분 포함)를 수취자(고객)에게 제공, 동적링크 시 LGPL 자체의 코드(수정이 있었다면 수정코드 포함)만 제공. 응용프로그램과 LGPL 코드를 분리하여 정의
  • MPL: MPL 코드가 포함된 파일만 수취자(고객)에게 제공. 실행파일은 별도의 라이선스(예를들어 자사의 상용라이선스)로 배포하는 것을 허용. 물론 MPL 소스코드는 MPL로 제공해야 함
참고로 공개SW 라이선스의 의무사항 준수 의무는 공개SW 원본 그 자체 혹은 이를 이용하여 개발한 소프트웨어를 타인에게 배포하는 경우에 발생한다.

Webpack 환경에서 CKEditor 5 사용하기

CKEditor Home : https://ckeditor.com/

CKEditor 5 를 사용할 예정이기 때문에 https://ckeditor.com/ckeditor-5/ 웹 페이지를 방문한다.  webpack 환경에서 build 된 Classic  버전을 설치하여 사용할 예정이기 때문에 아래 절차로 설치를 진행한다. 참고로 설치를 위한 webpack 기반 프로젝트가 필요하다. 새로운 프로젝트 생성이 필요하다면 Webpack Starter Template 를 참고하자.


CKEditor 5 Classic 를 선택하고 
② Commend line 를  복사하여 설치한다. 


③ VisualStudio Code 를 사용하여 개발을 진행하기 때문에 툴에서 명령어를 입력하며 추가한다. 

npm install --save @ckeditor/ckeditor5-build-classic

웹 프로그램에서 CKEditor 5 를 사용하려면 다음과 작업이 필요하다.

❶ 소스에서 CKEditor 5 Classic build 를 아래와 같이 import 한다.

   
// Using ES6 imports:
import ClassicEditor from '@ckeditor/ckeditor5-build-classic';

   

❷ CKEditor 를 생성하는 코드를 추가한다. 

   
   ClassicEditor
    .create( document.querySelector( '#ckeditor' ) )
    .then( editor => {
        window.ckeditor = editor ;
    } )
    .catch( error => {
        console.error( error );
    } );
   
   

❸ HTML 코드에 CKEditor 5 가 위치할 태그를 추가한다. <textarea/> 아닌 태그를 사용해도 무관하다.

    
   
      
   
  
   
   


빌드 후 해당 웹 페이지를 확인해보면 이미지 업로드를 시도하면 내부적으로 "filerepository-no-upload-adapter: Upload adapter is not defined." 오류로 인하여 아무런 반응이 없다. (→ 개발자 콘솔에서 확인 가능)




Custom Image Upload Adaptor 구현을 통한 이미지 업로드

CKEditor5 에서 이미지를 업로드 하는 방법은 공식 업로드 어뎁터들을 사용하거나 직접 커스텀 어뎁터를 만들어 자유롭게 사용하는 방법이 있다.  자료를 검색해보니 어려워보이지 않아 커스텀 어뎁터를 만들어 사용해 보았다.

The visualization of the image upload process in a WYSIWYG editor.


가이드 및 Stackoverflow 내용을 바탕으로 구현해보았으나 이미지 파일이 [object Promise] 형태로 업로드 되는 이슈가 발생되었다. CKEditor 12.0.0 버전의 경우 커스텀 이미지 업로드 관련하여 유사한  버그가 있었다는 것을 확인할 수 있었다.  버전 문제가 아닌가 의심하여 @ckeditor/ckeditor5-build-classic 21.0.0, 20.0.0, 19.0.2 버전에서 테스트를 해보았으나 이미지 파일이 [object Promise] 형태로 업로드 되는 동일한 이슈가 발생되었다. 


결국 아래의 게시물에서 제시하는 트릭을 발견하여 문제를 해결할 수 있었다. 


❶ upload() , abort() 을 구현하는 CustomUploadAdaptor 클래스를 구현한다. 

class StudioUploadAdapter {
  constructor(loader) {
    // CKEditor5's FileLoader instance.
    this.loader = loader;
    // URL where to send files.
    this.url = `${API_ROOT_URL}/data/images/0/upload`;
  }
  // Starts the upload process.
  upload() {
    return new Promise((resolve, reject) => {
      this._initRequest();
      this._initListeners(resolve, reject);
      this._sendRequest();
    });
  }
  // Aborts the upload process.
  abort() {
    if (this.xhr) {
      this.xhr.abort();
    }
  }
  // Example implementation using XMLHttpRequest.
  _initRequest() {
    const xhr = (this.xhr = new XMLHttpRequest());
    xhr.open("POST", this.url, true);
    xhr.responseType = "json";
  }
  // Initializes XMLHttpRequest listeners.
  _initListeners(resolve, reject) {
    const xhr = this.xhr;
    const loader = this.loader;
    const genericErrorText = "Couldn't upload file:" + ` ${loader.file.name}.`;
    xhr.addEventListener("error", () => reject(genericErrorText));
    xhr.addEventListener("abort", () => reject());
    xhr.addEventListener("load", () => {
      const response = xhr.response;
      console.log(response);
      if (!response || response.error) {
        return reject(
          response && response.error ? response.error.message : genericErrorText
        );
      }
      // If the upload is successful, resolve the upload promise with an object containing
      // at least the "default" URL, pointing to the image on the server.
      resolve({
        default: getDownloadUrl(response[0]),
      });
    });
    if (xhr.upload) {
      xhr.upload.addEventListener("progress", (evt) => {
        if (evt.lengthComputable) {
          loader.uploadTotal = evt.total;
          loader.uploaded = evt.loaded;
        }
      });
    }
  }
  // Prepares the data and sends the request.
  _sendRequest() {
    const data = new FormData();
    let that = this;
    let file = that.loader.file;
    
    // set jwt token if required.
    that.xhr.setRequestHeader("Authorization", authHeader().Authorization);
    file.then(function (result) {
      //wait for the promise to finish then continue
      data.append("upload", result);
      that.xhr.send(data);
    });
  }
}

/**
 * return full image url with responsed JSON object
 * 
 * @param {*} item 
 */
function getDownloadUrl(item) {
  return encodeURI(`${API_ROOT_URL}/download/images/${item.linkId}`);
}


❷ 에디터에서 CustomUploadAdaptor 클래스를 업로드 어댑터로 활성화하는 FileRepository.createUploadAdapter() 팩토리 함수를 정의한다.

   

function StudioUploadAdapterPlugin(editor) {
  editor.plugins.get("FileRepository").createUploadAdapter = (loader) => {
    return new StudioUploadAdapter(loader);
  };
}

❸ 에디터를 생성할 때 config.extraPlugins 옵션을 사용하여 편집기에서 CustomUploadAdapterPlugin을 활성화합니다.

   
function createCKEditor5(renderTo, options, callback) {
  renderTo = renderTo || $("#ckeditor");
  var $headers = headerWithAuth({});
  return ClassicEditor.create(renderTo.get(0), {
    extraPlugins: [StudioUploadAdapterPlugin],
    mediaEmbed: {
      previewsInData: true,
      extraProviders: [
        {
          name: "me",
          url: /^.*\/download/,
          html: match => {
            const id = match[ 1 ];
            return 'hello';
          }
        },
      ],
    },
    image: {
      // Configure the available styles.
      styles: ["alignLeft", "alignCenter", "alignRight"],      
      // You need to configure the image toolbar, too, so it shows the new style
      // buttons as well as the resize buttons.
      toolbar: [
        "imageStyle:alignLeft",
        "imageStyle:alignCenter",
        "imageStyle:alignRight",
        "|",
        "imageTextAlternative",
      ],
    },
  })
    .then((editor) => {
      window.ckeditor = editor;
      if (isFunction(callback)) {
        callback(editor);
      }
    })
    .catch((error) => {
      console.error("There was a problem initializing the editor.", error);
    });
}


서버 API

서버 API 는 Spring 를 기반으로 업로드된 이미지에 대한 정보를 JSON 형태로 응답하게 작성하면 된다. 

    
    // 자신의 환경에 맞게 구현이 필요. 
    @Secured({ "ROLE_ADMINISTRATOR", "ROLE_SYSTEM", "ROLE_DEVELOPER", "ROLE_USER"})
    @RequestMapping(value = "/images/0/upload", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
    @ResponseBody
    public List uploadAndRetureLink(
    		@RequestParam(value = "objectType", defaultValue = "-1", required = false) Integer objectType,
    		@RequestParam(value = "objectId", defaultValue = "-1", required = false) Long objectId,
    		@RequestParam(value = "imageId", defaultValue = "0", required = false) Long imageId,
    		@RequestHeader MultiValueMap headers,
    		MultipartHttpServletRequest request) throws NotFoundException, IOException, UnAuthorizedException { 
		
		User user = SecurityHelper.getUser();
		if( user.isAnonymous() )
		    throw new UnAuthorizedException(); 
		
		if( log.isDebugEnabled() ) {
			headers.forEach((key, value) -> {
		        log.debug(String.format( "Header '%s' = %s", key, value.stream().collect(Collectors.joining("|"))));
		    });
		}
		
		List list = new ArrayList(); 		
		Iterator names = request.getFileNames();
		while (names.hasNext()) {
		    String fileName = names.next();
		    log.debug("multipart name : {}", fileName );
		    MultipartFile mpf = request.getFile(fileName);
		    Image image = upload( user , objectType, objectId, imageId, mpf); 		    
		    ImageLink link = imageService.getImageLink(image, true);
		    link.setFilename(image.getName());
		    list.add( link ) ; 
		}
		return list ; 
    } 
    
    


사용후기

CKEditor5 는 컴포넌트 기반으로 동작하며 모듈화가 잘되어 있다. 처음 사용해보았지만 매뉴얼도 잘되어 있어 큰 어려움 없이 설치가 가능하였다. 

모든 기능을 모듈을 통하여 구현하게 되는 장점이 가장 불편하기도 하다. 즉 임으로 HTML 테그를 추가하는 것이 불가하기 때문이다. 이미지 업로드 후 원하는 형식으로 이미지 태그를 사용하고 싶은데 이를 위해서는 별도의 플러그 인을 만들어 사용해야 하는 것 같다.

댓글 없음:

댓글 쓰기