Quill Editor 내 이미지 삽입/ 붙여넣기 (base64) 핸들링

Record For a Better Life ·2023년 12월 8일
1

웹페이지 내에 에디터를 쉽게 임베딩 할 수 있는 Quill.js는 정말 간편하게 사용할 수 있는 자바스크립트용 툴이다.

https://quilljs.com/docs/quickstart/
공식문서에도 굉장히 깔끔하게 정리가 되어있지만, 사진 업로드 등의 후처리가 필요한 부분이 있어서 간단하게 정리하고자 한다.

어떤 에디터인지 간단하게 체험해보고 싶다면, 공식 사이트에서 playground를 통해 확인할 수 있다.

https://quilljs.com/playground/

기본 구성

<!-- Include stylesheet -->
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">

<!-- Include the Quill library -->
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>

cdn을 통해 css 및 js파일을 import하고, Quill 객체를 생성한다. 이 과정만 거치면 기본적인 에디터가 나타나게 된다.

옵션은 공식문서에서 필요한 부분만 가져오면 된다.

html

      <div id="editor" style="height: 400px"></div>

javascript

    var toolbarOptions = [
        ['bold', 'italic', 'underline', 'strike'],
        [{ 'header': 1 }, { 'header': 2 }],
        [{ 'list': 'ordered'}, { 'list': 'bullet' }],

        [{ 'color': [] }, { 'background': [] }],
        ['image', 'link'],

    ];

    function quilljsediterInit(){
        var option = {
            modules: {
                toolbar: toolbarOptions
            },
            theme: 'snow'
        };

        quill = new Quill('#editor', option);
    }

추가 설정

  1. Form을 통해 바로 파라미터로 값을 넘기고 싶어서, 에디터의 내용을 받아올 별도의 textarea (id = taskDetails)를 만들어 주고 text에 변동이 있을 시 바로 반영되도록 코드를 추가했다.
<label for="taskDetails">내용</label>
<textarea name = "taskDetails" id="taskDetails" rows="16" class="hidden"></textarea>
        quill.on('text-change', function() {
            document.getElementById("taskDetails").value = quill.root.innerHTML;
        });
  1. 이미지를 삽입할 때, 현재 서버에서 이미지를 별도로 저장한 후 해당 경로를 반환하게 구성했다.
        quill.getModule('toolbar').addHandler('image', function () {
            selectLocalImage();
        });

quilljsediterInit 함수 내부에 이미지에 대한 핸들러를 추가해준다.

function selectLocalImage() {
        const fileInput = document.createElement('input');
        fileInput.setAttribute('type', 'file');
        fileInput.accept = "image/*";

        fileInput.click();

        fileInput.addEventListener("change", function () {  // change 이벤트로 input 값이 바뀌면 실행

            if ($(this).val() !== "") { // 파일이 있을때만.

                var ext = $(this).val().split(".").pop().toLowerCase();

                if ($.inArray(ext, ["gif", "jpg", "jpeg", "png", "bmp"]) == -1) {

                    alert("jpg, jpeg, png, bmp, gif 파일만 업로드 가능합니다.");
                    return;
                }


                var fileSize = this.files[0].size;

                var maxSize = 20 * 1024 * 1024;

                if (fileSize > maxSize) {

                    alert("업로드 가능한 최대 이미지 용량은 20MB입니다.");

                    return;

                }

                const formData = new FormData();
                const file = fileInput.files[0];
                formData.append('uploadFile', file);

                $.ajax({
                    type: 'post',
                    enctype: 'multipart/form-data',
                    url: '/file/upload',
                    data: formData,
                    processData: false,
                    contentType: false,
                    dataType: 'text',
                    success: function (data) {
                        const range = quill.getSelection();
                        quill.insertEmbed(range.index, 'image', "/file/display?fileName=" + data);

                    },
                    error: function (err) {
                        console.log('ERROR!! ::');
                        console.log(err);
                    }
                });

            }

        });
    }

이미지 업로드 시 확장자와 이미지 사이즈를 제한하고, FormData형식으로 ajax를 통해 서버에 upload를 요청한다. 이 때 enctype을 'multipart/form-data'로 명시하지 않으면 오류가 발생한다.
upload 성공 시 선택한 index에 해당하는 이미지 파일을 보여줄 수 있도록 한다.

따라서 구현해야 하는 api는 두 개가 있다.

1. 이미지를 업로드하는 api
2. 이미지를 보여주는 api



API 구성

@RestController
@Slf4j
@RequestMapping("/file")
public class FileRestController {

    /**
     * 에디터 내 사진 파일 업로드
     * @param uploadFile
     * @return savePath - 저장경로
     */
    @RequestMapping(value = "/upload", method = RequestMethod.POST)
    public String uploadTestPOST(MultipartFile[] uploadFile) {

        String savePath;

        // OS 따라 구분자 분리
        String os = System.getProperty("os.name").toLowerCase();
        if (os.contains("win")){
            savePath = System.getProperty("user.dir") + "\\files\\image";
        }
        else{
            savePath = System.getProperty("user.dir") + "/files/image";
        }

        java.io.File uploadPath = new java.io.File(savePath);

        // 파일 저장 경로가 없으면 신규 생성
        if (!uploadPath.exists()) {
            uploadPath.mkdirs();
        }

        for (MultipartFile multipartFile : uploadFile) {

            String uploadFileName = multipartFile.getOriginalFilename();

            String uuid = UUID.randomUUID().toString();

            // 파일명 저장
            uploadFileName = uuid + "_" + uploadFileName;

            java.io.File saveFile = new java.io.File(uploadPath, uploadFileName);

            try {
                multipartFile.transferTo(saveFile);
                return uploadFileName;
            } catch (Exception e) {
                throw new CustomException(ErrorCode.BAD_REQUEST);
            }
        }
        return savePath;
    }

    /**
     * 에디터 내 사진 파일 첨부
     * @param fileName
     * @return
     */
    @ResponseBody
    @GetMapping(value = "/display")
    public ResponseEntity<byte[]> showImageGET(
            @RequestParam("fileName") String fileName
    ) {

        String savePath;

        // OS 따라 구분자 분리
        String os = System.getProperty("os.name").toLowerCase();
        if (os.contains("win")){
            savePath = System.getProperty("user.dir") + "\\files\\image\\";
        }
        else{
            savePath = System.getProperty("user.dir") + "/files/image/";
        }

        // 설정한 경로로 파일 다운로드
        java.io.File file = new java.io.File(savePath + fileName);

        ResponseEntity<byte[]> result = null;

        try {

            HttpHeaders header = new HttpHeaders();
            header.add("Content-type", Files.probeContentType(file.toPath()));

            result = new ResponseEntity<>(FileCopyUtils.copyToByteArray(file), header, HttpStatus.OK);

        } catch (NoSuchFileException e){
            log.error("No Such FileException {}", e.getFile());
        } catch (IOException e) {
            log.error(e.getMessage());
        }

        return result;
    }

여기까지 한다면 에디터 내에서의 기본적인 이미지 삽입은 구현이 되었다.
하지만 문제가 하나 더 있었는데, 바로 이미지/스크린샷을 에디터 내에 복사, 붙여넣기 하는 경우였다.

기본적으로 Quill 에디터에서 base64 형식으로 이미지를 넣게 되는데, 이 경우 data:image/png;base64 라는 형태의 매우 긴 문자열이 들어가게 된다.

이런 문자열이 끝도 없이 보이는 공포스러운 상황이 발생한다...

당장 오류가 발생하는 부분은 아니지만, 이렇게 들어간 img 태그는 매우 긴, 심하면 몇 천자 정도의 길이를 가진 문자열이기 때문에 http 요청 시 부하가 심하다.
tomcat 등 서버의 max-http-form-post-size 를 낮게 잡아놨다면 요청 사이즈를 넘었다고 오류가 발생하는 경우를 볼 수 있을 것이다.

따라서 나는 form을 제출 시에 base64 형식의 img 태그를 모두 로컬의 이미지 경로로 바꾸어 주었다.



Base64 이미지 변환

제출 시 validate하는 코드 내에 이 부분을 추가하였다.

			const imgTags = document.querySelectorAll('img');

            const ajaxRequests = [];

            imgTags.forEach(function(img) {
                var currentSrc = img.getAttribute('src');

                // 이미지가 base64로 인코딩되어 있는지 확인
                if (currentSrc.startsWith('data:image')) {

                    // base64 데이터 추출
                    const splitDataURI = currentSrc.split(',')

                    if (splitDataURI[0].indexOf('base64') >= 0){
                        const ajaxRequest = $.ajax({
                            type: 'post',
                            enctype: 'multipart/form-data',
                            url: '/file/uploadBase64',
                            data: splitDataURI[1],
                            processData: false,
                            contentType: false,
                            dataType: 'text',
                            async: false,
                            success: function (data) {
                                img.setAttribute('src', "/file/display?fileName=" + data);
                            },
                            error: function (err) {
                                console.log('ERROR!! ::');
                                console.log(err);
                            }
                        });

                        ajaxRequests.push(ajaxRequest);

                    }
                }
            });

            if (ajaxRequests.length===0){
                return true;
            }
            // 모든 AJAX 요청이 완료된 후에 폼 제출
            $.when.apply($, ajaxRequests).done(function () {
                return true;
            }).fail(function () {
                return false;
            });

base64 형식의 이미지 태그만 찾은 뒤, 위에 일반적인 이미지 삽입과 비슷한 코드이다.
다만 base64 이미지를 변환 후 로컬에 저장하기 위한 별도의 api를 작성해주어야 한다.
이전에 작성한 FileRestController 클래스에 아래 메소드를 추가했다.

@RequestMapping(value = "/uploadBase64", method = RequestMethod.POST)
    public String handleBase64Upload(@RequestBody String base64Image) {
        try {
            int maxLength = 20;

            String filename = truncateAndAppendTimestamp(base64Image, maxLength) + ".png";
            String savePath;
            String filePath;

            String os = System.getProperty("os.name").toLowerCase();
            if (os.contains("win")){
                savePath = System.getProperty("user.dir") + "\\files\\image";
                filePath = savePath + "\\" + filename;
            }
            else{
                savePath = System.getProperty("user.dir") + "/files/image";
                filePath = savePath + "/" + filename;
            }

            if (!new java.io.File(savePath).exists()) {
                try{
                    new java.io.File(savePath).mkdir();
                }
                catch(Exception e){
                    e.getStackTrace();
                }
            }

            java.io.File file = new File(filePath);

            // BASE64를 일반 파일로 변환하고 저장합니다.
            java.util.Base64.Decoder decoder = Base64.getMimeDecoder();
            byte[] decodedBytes = decoder.decode(base64Image.getBytes());
            FileOutputStream fileOutputStream = new FileOutputStream(file);
            fileOutputStream.write(decodedBytes);
            fileOutputStream.close();

            return filename;
        } catch (IOException e) {
            log.error(e.getMessage());

            return "File upload failed.";
        }
    }

    public static String truncateAndAppendTimestamp(String base64Image, int maxLength) {
        // 제거할 특수문자 정규식
        String specialCharactersRegex = "[^a-zA-Z0-9]";

        String truncatedBase64Image = base64Image.length() > maxLength
                ? base64Image.substring(base64Image.length() - maxLength)
                : base64Image;

        // 특수문자를 제거하고 timestamp 생성
        String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS"));
        timestamp = timestamp.replaceAll(specialCharactersRegex, "");

        // 특수문자를 제거한 timestamp를 포함하여 결과 문자열 생성
        return new StringJoiner("_")
                .add(truncatedBase64Image.replaceAll(specialCharactersRegex, ""))
                .add(timestamp)
                .toString();
    }

BASE64를 일반 파일로 변환하고 저장한다. 이때 저장할 파일명은 생성날짜와 함께 기존의 base64 문자열의 뒷 20자리를 사용했는데, 이 때 특수문자가 있으면 오류가 발생하므로 제외해주었다.

profile
모든 것을 기록하는 벨로그 💻

0개의 댓글