웹페이지 내에 에디터를 쉽게 임베딩 할 수 있는 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);
}
<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;
});
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
@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 태그를 모두 로컬의 이미지 경로로 바꾸어 주었다.
제출 시 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자리를 사용했는데, 이 때 특수문자가 있으면 오류가 발생하므로 제외해주었다.