노현진's Blog

Next.js Quill 이미지 처리 방법

Next.js Quill 이미지 처리 방법에 대해 설명하는 페이지입니다.

Posted
Preview Image
By HyunJinNo

Tags

TypeScript, Next.js, Quill, Image

Environment

Next.js v14.2.3

react-quill v2.0.0

1. 개요

이번 글에서는 Quill 에디터를 사용하여 이미지를 삽입할 때 base64 형식 대신 URL을 사용하는 법이미지 크기를 조절하는 법, 그리고 이미지 drag & drop 적용 방법을 설명하겠습니다.

2. Step 1 - 사전 준비

이번 글에서 사용하는 에디터는 Quill 에디터로, ReactQuill 패키지를 설치하여 사용합니다. Next.js에서 ReactQuill을 사용하는 방법은 다음 링크를 참고하시길 바랍니다.

Next.js에서 ReactQuill 사용 방법

3. Step 2 - Quill 이미지 관련 패키지 설치하기

Quill 에디터의 이미지 크기를 조절하기 위해 사용하는 패키지는 여러 가지가 존재합니다. 이번 글에서는 quill-image-resize-module-ts 패키지를 설치하겠습니다. 또한 이미지 drag & drop 기능을 적용하기 위해 quill-image-drop-and-paste 패키지를 설치하겠습니다. 다음 명령어를 입력하여 quill-image-resize-module-ts 패키지와 quill-image-drop-and-paste 패키지를 설치합니다.

bash
1npm install quill-image-resize-module-ts quill-image-drop-and-paste

4. Step 3 - Ref 객체

다음과 같이 Ref 객체를 정의합니다.

typescript
1const quillRef = useRef<ReactQuill>(null);

quillRef는 아래의 이미지 핸들러를 통해 Quill 에디터에 이미지를 추가할 때 사용하는 Ref 객체입니다. Ref 객체를 사용함으로써 이미지를 추가할 때 img 태그의 src 부분에 base64 대신 URL을 사용할 수 있습니다.

Caution

주의할 점으로 Quill 에디터에서 Ref 객체를 사용하기 위해선 ReactQuill 모듈을 Next.js에서 지원하는 next/dynamic동적 임포트(dynamic import)하는 방법을 사용하면 안됩니다. 동적 임포트를 사용하는 경우 ReactQuill에서 Ref를 지정하려고 하면 오류가 발생합니다. 따라서 일반적인 방법으로 ReactQuill 모듈을 임포트하되, 해당 Quill 에디터를 사용하는 컴포넌트 자체를 동적 임포트해야 합니다.

5. Step 4 - 이미지 핸들러 구현하기

5.1. 이미지를 추가할 때 이미지 처리를 수행하는 이미지 핸들러

다음과 같이 Quill 에디터에서 이미지를 추가할 때 이미지 처리를 수행하는 이미지 핸들러를 정의합니다. 먼저 단순히 버튼을 클릭하여 이미지 처리를 수행하는 이미지 핸들러를 정의합니다.

typescript
1const imageHandler = () => {
2  // Step 1. 이미지 파일을 첨부할 수 있는 input을 생성합니다.
3  const input = document.createElement("input");
4  input.setAttribute("type", "file");
5  input.setAttribute("accept", "image/*");
6
7  // Step 2. 이미지 핸들러 실행 시, input 클릭 이벤트를 발생시킵니다.
8  input.click();
9
10  // Step 3. change 이벤트가 발생했을 때의 이미지 처리 로직을 적용합니다.
11  input.addEventListener("change", () => {
12    if (input.files && quillRef.current) {
13      const file = input.files[0];
14      const blob = new Blob([file], { type: "image/png" });
15      const url = URL.createObjectURL(blob);
16
17      const Image = Quill.import("formats/image");
18      Image.sanitize = (url: string) => url;
19
20      const editor = quillRef.current.getEditor();
21      const range = editor.getSelection();
22
23      if (range) {
24        editor.insertEmbed(range.index, "image", url);
25        editor.setSelection(range.index + 1, 0);
26
27        // 이미지가 DOM에 추가된 후 이미지에 스타일을 적용하기 위해 setTimeout 사용합니다.
28        setTimeout(() => {
29          const imageElement = document.querySelector(
30            `img[src="${response.fileUrl}"]`,
31          );
32          if (imageElement) {
33            (imageElement as HTMLElement).style.borderRadius = "1rem";
34            formContext.setValue(
35              "contents",
36              quillRef.current!.getEditorContents().toString(),
37            );
38          }
39
40          // 메모리 누수를 방지하기 위해 URL을 해제합니다.
41          URL.revokeObjectURL(url);
42        }, 100);
43      }
44    }
45  });
46};

위의 코드를 설명하자면 다음과 같습니다.

  • 이미지 파일을 첨부할 수 있는 input 생성

    typescript
    1// Step 1. 이미지 파일을 첨부할 수 있는 input을 생성합니다.
    2const input = document.createElement("input");
    3input.setAttribute("type", "file");
    4input.setAttribute("accept", "image/*");

    먼저 이미지 파일을 첨부할 수 있는 input을 생성해야 합니다. input의 타입을 file로 지정한 뒤 이미지 파일을 첨부할 수 있도록 설정합니다.

  • input 클릭 이벤트 실행

    typescript
    1// Step 2. 이미지 핸들러 실행 시, input 클릭 이벤트를 발생시킵니다.
    2input.click();

    이미지 핸들러가 실행될 경우 이미지를 첨부할 수 있도록 input 클릭 이벤트를 발생시킵니다.

  • 이미지 처리 로직 작성

    typescript
    1// Step 3. change 이벤트가 발생했을 때의 이미지 처리 로직을 적용합니다.
    2input.addEventListener("change", () => {

    input의 change 이벤트가 발생했을 때의 이미지 처리 이벤트를 등록합니다.

  • 이미지 URL 생성

    typescript
    1const file = input.files[0];
    2const blob = new Blob([file], { type: "image/png" });
    3const url = URL.createObjectURL(blob);

    먼저 파일을 blob 객체로 변환시킵니다. 이후 변환된 blob 객체를 통해 이미지 URL를 생성합니다.

  • Image.sanitize

    typescript
    1const Image = Quill.import("formats/image");
    2Image.sanitize = (url: string) => url;

    위에서 생성된 이미지 URL을 Quill 에디터에서 사용하려고 하면 "//:0"와 같이 추가되는 문제가 있습니다. 해당 문제를 해결하기 위해 위와 같이 Image.sanitize 로직을 작성합니다.

  • 이미지 추가

    typescript
    1const editor = quillRef.current.getEditor();
    2const range = editor.getSelection();
    3
    4if (range) {
    5  editor.insertEmbed(range.index, "image", url);
    6  editor.setSelection(range.index + 1, 0);
    7
    8  /* ... */
    9}

    생성된 이미지 URL을 사용하여 Quill 에디터에 이미지를 삽입합니다.

  • 이미지 style 지정

    typescript
    1// 이미지가 DOM에 추가된 후 이미지에 스타일을 적용하기 위해 setTimeout 사용합니다.
    2setTimeout(() => {
    3  const imageElement = document.querySelector(
    4    `img[src="${response.fileUrl}"]`,
    5  );
    6  if (imageElement) {
    7    (imageElement as HTMLElement).style.borderRadius = "1rem";
    8    formContext.setValue(
    9      "contents",
    10      quillRef.current!.getEditorContents().toString(),
    11    );
    12  }
    13
    14  // 메모리 누수를 방지하기 위해 URL을 해제합니다.
    15  URL.revokeObjectURL(url);
    16}, 100);

    삽입된 이미지의 style을 변경하고 싶은 경우 위와 같이 추가된 이미지에 접근할 수 있습니다. 위의 예시는 이미지의 border-radius을 1rem으로 지정하는 코드입니다.

5.2. drag & drop을 처리하는 이미지 핸들러

다음으로 이미지 drag & drop을 처리하는 이미지 핸들러를 정의합니다.

typescript
1/**
2 * @param imageDataUrl image's dataURL
3 * @param type image's mime type
4 * @param imageData provided more functions to handle the image
5 * - imageData.toBlob() {function} - convert image to a BLOB Object
6 * - imageData.toFile(filename?: string) {function} - convert image to a File Object. filename is optional, it will generate a random name if the original image didn't have a name.
7 * - imageData.minify(options) {function)- minify the image, return a promise
8 *   - options.maxWidth {number} - specify the max width of the image, default is 800
9 *   - options.maxHeight {number} - specify the max height of the image, default is 800
10 *   - options.quality {number} - specify the quality of the image, default is 0.8
11 */
12const imageDropAndPasteHandler = async (
13  imageDataUrl: string,
14  type: string,
15  imageData: ImageData,
16) => {
17  const file = imageData.toFile();
18
19  if (!file || !quillRef.current) {
20    return;
21  }
22
23  const blob = new Blob([file], { type: "image/png" });
24  const url = URL.createObjectURL(blob);
25
26  const Image = Quill.import("formats/image");
27  Image.sanitize = (url: string) => url;
28
29  const editor = quillRef.current.getEditor();
30  const range = editor.getSelection();
31
32  if (range) {
33    editor.insertEmbed(range.index, "image", url);
34    editor.setSelection(range.index + 1, 0);
35
36    // 이미지가 DOM에 추가된 후 이미지에 스타일을 적용하기 위해 setTimeout 사용합니다.
37    setTimeout(() => {
38      const imageElement = document.querySelector(
39        `img[src="${response.fileUrl}"]`,
40      );
41      if (imageElement) {
42        (imageElement as HTMLElement).style.borderRadius = "1rem";
43        formContext.setValue(
44          "contents",
45          quillRef.current!.getEditorContents().toString(),
46        );
47      }
48
49      // 메모리 누수를 방지하기 위해 URL을 해제합니다.
50      URL.revokeObjectURL(url);
51    }, 100);
52  }
53};

위의 코드를 보면 알 수 있듯이, 이미지를 가져오는 부분만 다르고 비즈니스 로직은 동일합니다.

6. Step 5 - Quill 모듈

다음과 같이 Quill 모듈을 정의합니다.

typescript
1const modules = useMemo(() => {
2  ReactQuill.Quill.register("modules/imageResize", ImageResize);
3  ReactQuill.Quill.register("modules/imageDropAndPaste", ImageDropAndPaste);
4
5  return {
6    // 더 많은 옵션은 다음 링크를 참고할 것.
7    // https://quilljs.com/docs/modules/toolbar
8    toolbar: {
9      container: [
10        [{ size: ["small", false, "large", "huge"] }, { font: [] }],
11        [{ color: [] }, { background: [] }],
12        [{ list: "ordered" }, { list: "bullet" }, { list: "check" }],
13        ["bold", "italic", "underline", "strike"],
14        [{ indent: "-1" }, { indent: "+1" }, { align: [] }],
15        ["link", "image", "video"],
16      ],
17      handlers: { image: imageHandler },
18    },
19    imageResize: {
20      modules: ["Resize", "DisplaySize", "Toolbar"],
21      handleStyles: {
22        backgroundColor: "#00B488",
23        border: "none",
24        // other camelCase styles for size display
25      },
26    },
27    imageDropAndPaste: {
28      // add an custom image handler
29      handler: imageDropAndPasteHandler,
30    },
31  };
32}, []);

위의 코드를 설명하자면 다음과 같습니다.

  • useMemo()
    렌더링이 발생할 때 모듈 객체가 재생성되는 것을 방지합니다.
  • ReactQuill.Quill.register("modules/imageResize", ImageResize);
    이미지 크기 수정 모듈을 적용합니다.
  • handlers: { image: imageHandler }
    위에서 정의한 이미지 핸들러를 적용합니다.
  • imageResize
    이미지 크기 수정 기능을 적용합니다.
  • imageDropAndPaste
    이미지 drag & drop 기능을 적용합니다.

7. Step 6 - 최종 코드

최종 코드는 다음과 같습니다.

7.1. QuillEditorContainer.tsx

typescript
1"use client";
2
3import QuillEditor from "@/components/diary/write/QuillEditor";
4import useDiaryEditorStore from "@/store/diaryEditorStore";
5import { ImageResize } from "quill-image-resize-module-ts";
6import ImageDropAndPaste, { ImageData } from "quill-image-drop-and-paste";
7import { useMemo, useRef } from "react";
8import ReactQuill, { Quill } from "react-quill";
9
10const QuillEditorContainer = () => {
11  const diaryEditorStore = useDiaryEditorStore();
12  const quillRef = useRef<ReactQuill>(null);
13
14  const imageHandler = () => {
15    // Step 1. 이미지 파일을 첨부할 수 있는 input을 생성합니다.
16    const input = document.createElement("input");
17    input.setAttribute("type", "file");
18    input.setAttribute("accept", "image/*");
19
20    // Step 2. 이미지 핸들러 실행 시, input 클릭 이벤트를 발생시킵니다.
21    input.click();
22
23    // Step 3. change 이벤트가 발생했을 때의 이미지 처리 로직을 적용합니다.
24    input.addEventListener("change", () => {
25      if (input.files && quillRef.current) {
26        const file = input.files[0];
27        const blob = new Blob([file], { type: "image/png" });
28        const url = URL.createObjectURL(blob);
29
30        const Image = Quill.import("formats/image");
31        Image.sanitize = (url: string) => url;
32
33        const editor = quillRef.current.getEditor();
34        const range = editor.getSelection();
35
36        if (range) {
37          editor.insertEmbed(range.index, "image", url);
38          editor.setSelection(range.index + 1, 0);
39
40          // 이미지가 DOM에 추가된 후 이미지에 스타일을 적용하기 위해 setTimeout 사용합니다.
41          setTimeout(() => {
42            const imageElement = document.querySelector(
43              `img[src="${response.fileUrl}"]`,
44            );
45            if (imageElement) {
46              (imageElement as HTMLElement).style.borderRadius = "1rem";
47              formContext.setValue(
48                "contents",
49                quillRef.current!.getEditorContents().toString(),
50              );
51            }
52
53            // 메모리 누수를 방지하기 위해 URL을 해제합니다.
54            URL.revokeObjectURL(url);
55          }, 100);
56        }
57      }
58    });
59  };
60
61  /**
62   * @param imageDataUrl image's dataURL
63   * @param type image's mime type
64   * @param imageData provided more functions to handle the image
65   * - imageData.toBlob() {function} - convert image to a BLOB Object
66   * - imageData.toFile(filename?: string) {function} - convert image to a File Object. filename is optional, it will generate a random name if the original image didn't have a name.
67   * - imageData.minify(options) {function)- minify the image, return a promise
68   *   - options.maxWidth {number} - specify the max width of the image, default is 800
69   *   - options.maxHeight {number} - specify the max height of the image, default is 800
70   *   - options.quality {number} - specify the quality of the image, default is 0.8
71   */
72  const imageDropAndPasteHandler = async (
73    imageDataUrl: string,
74    type: string,
75    imageData: ImageData,
76  ) => {
77    const file = imageData.toFile();
78
79    if (!file || !quillRef.current) {
80      return;
81    }
82
83    const blob = new Blob([file], { type: "image/png" });
84    const url = URL.createObjectURL(blob);
85
86    const Image = Quill.import("formats/image");
87    Image.sanitize = (url: string) => url;
88
89    const editor = quillRef.current.getEditor();
90    const range = editor.getSelection();
91
92    if (range) {
93      editor.insertEmbed(range.index, "image", url);
94      editor.setSelection(range.index + 1, 0);
95
96      // 이미지가 DOM에 추가된 후 이미지에 스타일을 적용하기 위해 setTimeout 사용합니다.
97      setTimeout(() => {
98        const imageElement = document.querySelector(
99          `img[src="${response.fileUrl}"]`,
100        );
101        if (imageElement) {
102          (imageElement as HTMLElement).style.borderRadius = "1rem";
103          formContext.setValue(
104            "contents",
105            quillRef.current!.getEditorContents().toString(),
106          );
107        }
108
109        // 메모리 누수를 방지하기 위해 URL을 해제합니다.
110        URL.revokeObjectURL(url);
111      }, 100);
112    }
113  };
114
115  const modules = useMemo(() => {
116    ReactQuill.Quill.register("modules/imageResize", ImageResize);
117    ReactQuill.Quill.register("modules/imageDropAndPaste", ImageDropAndPaste);
118
119    return {
120      // 더 많은 옵션은 다음 링크를 참고할 것.
121      // https://quilljs.com/docs/modules/toolbar
122      toolbar: {
123        container: [
124          [{ size: ["small", false, "large", "huge"] }, { font: [] }],
125          [{ color: [] }, { background: [] }],
126          [{ list: "ordered" }, { list: "bullet" }, { list: "check" }],
127          ["bold", "italic", "underline", "strike"],
128          [{ indent: "-1" }, { indent: "+1" }, { align: [] }],
129          ["link", "image", "video"],
130        ],
131        handlers: { image: imageHandler },
132      },
133      imageResize: {
134        modules: ["Resize", "DisplaySize", "Toolbar"],
135        handleStyles: {
136          backgroundColor: "#00B488",
137          border: "none",
138          // other camelCase styles for size display
139        },
140      },
141      imageDropAndPaste: {
142        // add an custom image handler
143        handler: imageDropAndPasteHandler,
144      },
145    };
146  }, []);
147
148  return (
149    <QuillEditor
150      quillRef={quillRef}
151      modules={modules}
152      content={diaryEditorStore.contents[diaryEditorStore.currentDay - 1]}
153      onChange={(value: string) =>
154        diaryEditorStore.changeContent(diaryEditorStore.currentDay - 1, value)
155      }
156    />
157  );
158};
159
160export default QuillEditorContainer;

7.2. QuillEditor.tsx

typescript
1import "react-quill/dist/quill.snow.css";
2import "@/styles/quillEditor.css";
3import ReactQuill from "react-quill";
4import { RefObject } from "react";
5
6interface Props {
7  quillRef: RefObject<ReactQuill>;
8  modules: {};
9  content: string;
10  onChange: (value: string) => void;
11}
12
13const QuillEditor = ({ quillRef, modules, content, onChange }: Props) => {
14  return (
15    <ReactQuill
16      ref={quillRef}
17      theme="snow"
18      placeholder="여행은 어땠나요? 자유롭게 기록하고 싶은 것들을 작성해보세요."
19      onChange={(value, delta, source, editor) => {
20        onChange(value);
21      }}
22      value={content}
23      modules={modules}
24    />
25  );
26};
27
28export default QuillEditor;

8. step 7 - Dynamic Import

다음과 같이 Quill 에디터를 사용하는 컴포넌트를 동적 임포트합니다.

typescript
1/* ... */
2
3import dynamic from "next/dynamic";
4import QuillEditorSkeleton from "@/components/skeleton/diary/write/QuillEditorSkeleton";
5
6const QuillEditorContainer = dynamic(
7  () => import("@/containers/diary/write/QuillEditorContainer"),
8  {
9    ssr: false,
10    loading: () => <QuillEditorSkeleton />
11  }
12);
13
14/* ... */

9. Step 8 - 테스트 결과

테스트 결과는 다음과 같습니다.

테스트 결과

10. 더 알아보기

위의 소스 코드는 제가 참여하고 있는 프로젝트의 소스 코드에서 가져온 것입니다. 프로젝트 내에서 사용된 방법을 확인하려면 다음 GitHub 링크를 참고하시길 바랍니다.

solitour-frontend

11. 참고 자료

© HyunJinNo. Some rights reserved.