React Quill 에디터 사용하기

HSKwon·2023년 5월 10일
15
post-thumbnail

1. 설치

사용하고 있는 패키지 매니저로 설치하면 된다.
yarn add react-quill 또는 npm i react-quill

2. 기본 사용방법 (toolbar 커스텀 X)

import React, {
  ReactChild,
  ReactFragment,
  RefObject,
  useMemo,
  useState,
} from 'react';
import ReactQuill, { Quill } from 'react-quill';
import 'react-quill/dist/quill.snow.css';

const formats = [
  'font',
  'header',
  'bold',
  'italic',
  'underline',
  'strike',
  'blockquote',
  'list',
  'bullet',
  'indent',
  'link',
  'align',
  'color',
  'background',
  'size',
  'h1',
];

export default function QuillEditor = () => {
  
 const [values, setValues] = useState();
  
 const modules = useMemo(() => {
    return {
      toolbar: {
        container: [
          [{ size: ['small', false, 'large', 'huge'] }],
          [{ align: [] }],
          ['bold', 'italic', 'underline', 'strike'],
          [{ list: 'ordered' }, { list: 'bullet' }],
          [
            {
              color: [],
            },
            { background: [] },
          ],
        ],
      },
    };
  }, []);

	return(
     <ReactQuill
      theme="snow"
      modules={modules}
      formats={formats}
      onChange={setValues}
    />
    )
}

다음과 같이 기본적인 React Quill 에디터가 나타난 것을 확인할 수 있다.

3. toolbar 커스텀하기

  • toolbar를 커스텀할 경우, CustomToolbar 컴포넌트를 만들고 import 해서 사용하면 된다. 최상단의 태그에는 toolbar를 id로 선언해준다.
export const CustomToolbar = () => (
  <div id="toolbar">
    <span className="ql-formats">
      <select className="ql-size" defaultValue="medium">
        <option value="small">Small</option>
        <option value="medium">Medium</option>
        <option value="large">Large</option>
        <option value="huge">Huge</option>
      </select>
      <select className="ql-header">
        <option value="1">Header 1</option>
        <option value="2">Header 2</option>
        <option value="3">Header 3</option>
        <option value="4">Header 4</option>
        <option value="5">Header 5</option>
        <option value="6">Header 6</option>
      </select>
    </span>
    <span className="ql-formats">
      <button className="ql-bold" />
      <button className="ql-italic" />
      <button className="ql-underline" />
      <button className="ql-strike" />
      <button className="ql-blockquote" />
    </span>
    <span className="ql-formats">
      <select className="ql-color" />
      <select className="ql-background" />
    </span>
    <span className="ql-formats">
      <button className="ql-image" />
      <button className="ql-video" />
    </span>
    <span className="ql-formats">
      <button className="ql-clean" />
    </span>
  </div>
);

커스텀한 toolbar를 사용하기 위해 React Quill 컴포넌트에서 선언해준 modules의 toolbar 객체의 container에 부여한 id값을 지정한다.

 const modules = useMemo(() => {
    return {
      toolbar: {
      container: "#toolbar",
     },
    };
  }, []);

4. editor 작성하고 확인하기

에디터에 글자를 입력하고, 각각 다르게 글자 색깔을 색상했다. 추가적으로 반갑습니다~부분에는 strong 효과를 주었다.

  • onChange에 바인딩해준 setValues를 통해 저장된 values 값을 console에 출력해보면 스타일이 지정된 html 태그 값이 정상적으로 찍히는걸 확인할 수 있다.

5. state에 저장한 태그 보여주기

<div
    dangerouslySetInnerHTML={{
     __html: DOMPurify.sanitize(values),
    }}
    style={{
     marginTop: '30px',
     overflow: 'hidden',
     whiteSpace: 'pre-wrap',
    }}
/>

정상적으로 잘 출력되었다!

✅ dangerouslySetInnerHTML? DOMPurify? 리액트의 공식문서에 의하면....

여기에서 dangerouslySetInnerHTML은 브라우저 DOM에서 innerHTML을 사용하기 위한 React의 대체 방법이다. 일반적으로 코드에서 HTML을 설정하는 것은 사이트 간 스크립팅 공격에 쉽게 노출될 수 있기 때문에 위험하다. 따라서 React에서 직접 HTML을 설정할 수는 있지만, 위험하다는 것을 상기시키기 위 dangerouslySetInnerHTML을 작성하고 __html 키로 객체를 전달해야 한다.

dangerouslySetInnerHTML을 이용할때 script가 포함되어 있으면 스크립팅 공격에 취약해지는데, 이러한 위험을 막기 위해 DOMPurify를 사용해줬다.

6. 이미지 업로드하기

  • react quill 에디터에 이미지를 업로드 해본다.
  • 콘솔에 어떤 값이 출력될까?

이미지를 업로드 하고 콘솔에 출력해보면 엄청나게 긴 문자열이 찍히는걸 확인할 수 있다. 이렇게 base64 형태로 백엔드 서버에 저장하면 서버에 엄청난 부하가 걸릴 것이다. 만약 업로드해야할 사진이 100장이라면....?

그래서 다른 방법으로 이미지를 처리해야 한다.

✅ 처리 방식

  1. file selector가 실행되게 하기 위한 input 요소(hidden)를 만들고 ref를 바인딩한다.
  2. 에디터 툴바에서 이미지 버튼을 클릭 시, input ref가 실행되어 file selector가 실행되도록한다.
  3. input이 클릭되어 file selector가 실행되고 삽입할 이미지를 선택한다 = change 이벤트 발생
  4. change 이벤트가 발생할것이고 response를 서버로부터 전달받는다.
  5. response와 displayUrl을 확인한다.
  6. 받은 displayUrl로 에디터의 현재 커서 위치에 삽입(insertEmbed)한다.

파일 업로드 matation을 위한 함수 useUploadFile을 react query로 만들었고 handleImageUpload 함수를 통해 file을 인자로 받아서 useUploadFile에 전달해줘서 업로드 하는 방식으로 구현했다.

  // 파일 업로드를 위한 커스텀 훅
  const uploadFileMutation = useUploadFile();

  async function handleImageUpload(file: File) {
    
  if (!file) {
    // 파일 선택이 취소된 경우 사용자에게 알려주기
    alert('파일이 선택되지 않았습니다.');
    return;
  }

  if (quillRef.current) {
    try {
      const result = await uploadFileMutation.mutateAsync(file);
      const editor = quillRef.current.getEditor();
      const range = editor.getSelection(true);
      
      // range가 있는지를 검사한다.
      // 만약 range가 null 이거나 undefined인경우 당연히 삽입할 대상의 위치가 
      // 없는것이므로 이미지가 삽입되지 않는다...!
      if (range) {
        editor.insertEmbed(range.index, 'image', result.displayUrl);
      } else {
        alert('에디터에 포커스를 맞추고 다시 시도해주세요.');
      }
    } catch (error) {
      alert('이미지 업로드 중 오류가 발생했습니다. 다시 시도해주세요.');
      console.error('Error:', error);
    }
  }
}

마지막으로 아까 만든 react quill 컴포넌트에 선언한 modules에 handlers 옵션을 추가해주면 된다.

가독성과 유지보수를 위해 handlers의 image에 바인딩된 함수는 커스텀훅으로 분리할 수도 있다.

  const modules = useMemo(() => {
    return {
      toolbar: {
        container: [
          [{ size: ['small', false, 'large', 'huge'] }],
          [{ align: [] }],
          ['bold', 'italic', 'underline', 'strike'],
          [{ list: 'ordered' }, { list: 'bullet' }],
          [
            {
              color: [],
            },
            { background: [] },
          ],
          ['image'],
        ],
		
        // ✅ 추가된 handlers 옵션
        
        handlers: {
          image: () => {
            const input = document.createElement('input');
            input.setAttribute('type', 'file');
            input.setAttribute('accept', 'image/*');
            input.addEventListener('change', () => {
              // 파일을 하나씩 업로드
              const file = input.files && input.files[0];
              handleImageUpload(file);
            });
            input.click();
          },
        },
      },
    };
  }, []);

⭐ 여러 파일을 다중으로 업로드하기

이전에는 개별 파일의 0번째 인덱스에 접근하여 파일을 하나씩 업로드했던 반면, 다중 이미지를 업로드 하기 위해서는 multiple 속성을 생성한 input 요소에 attribute 시켜준다.

const multiImageHandler = () => {
    const input = document.createElement('input');
    input.setAttribute('type', 'file');
    input.setAttribute('accept', 'image/*');
    input.setAttribute('multiple', '');

    input.addEventListener('change', () => {
      if (input.files) {
        handleMultipleImagesUpload(input.files);
      }
    });

    input.click();
  };

이번에는 multiImageHandler 함수를 생성하고, 툴바의 handers의 image에 함수를 바인딩해주자.

	...
    
   handlers: {
        image: multiImageHandler,
      },
        
    ...

multiImageHandler의 handleMultipleImagesUpload는 감지된 이미지 정보들을 파라미터로 받아서 이를 각각 순회하고 해당 값을 처리하는 로직인데, 나의 경우에는 전역상태관리 라이브러리인 jotai를 이용해서 이미지 자체를 아톰에 세팅했다.

	// 아톰 생성해주기
	export const multipleAtom = atom<NewFileDto[]>([]);
	
	// setter로만 사용할것이기 때문에 useSetAtom 훅으로 아톰 선언하기
	const setMultiImages = useSetAtom(multipleAtom);

	 const handleMultipleImagesUpload = useCallback(
      async (files: FileList) => {
        for (const file of files) {
          try {
            const res = await uploadFileMutation.mutateAsync(file);
            // 원하시는 로직을 작성하면 될거같다.
            setUploadImageStatus(res.displayUrl);
            setMultiImages(prev => [...prev, res]);
          } catch (error) {
            console.error(error);
          }
        }
      },
      [uploadFileMutation, setMultiImages],
  );
profile
공부한 내용이나 관심 있는 정보를 글로 정리하며 익숙하게 만들고자 합니다.

0개의 댓글