ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • React quill Editor + react-hook-form feat.TypeScript
    Studying/React 2023. 7. 11. 10:49

    개요

    Portfolly 프로젝트의 '포트폴리오 등록/수정 페이지'에 React Quill Editor와 react-hook-form을 사용해보겠습니다.

     

     

    React Quill Editor 초기 세팅하기

    먼저 React Quill 에디터를 입맛대로 초기 세팅합니다.

    import { styled } from 'styled-components';
    import tw from 'twin.macro';
    import { memo, useMemo, useRef } from 'react';
    import ReactQuill from 'react-quill';
    import "react-quill/dist/quill.snow.css";
    
    const QuillWrapper = styled.div`
    ${tw`z-10 absolute border-0 top-0`}
      .ql-toolbar {
        padding: 17px 0;
        background-color: #252525;
        border: 0;
        text-align: center;
      }
      .ql-stroke, .ql-fill {
        stroke: white;
      }
      .ql-picker-label {
        color: white;
      }
      .ql-container{
        border: 0;
      }
      .ql-editor{
        max-height: calc(100vh - 60px);
      }
    `
    
    const QuillEditor = memo(() => {
      const quillRef = useRef<ReactQuill>();
    
      const modules = useMemo(
        () => ({
          toolbar: {
            container: [
              [{ 'header': [1, 2, 3, 4, 5, 6, false] }],
              [{ 'font': [] }],
              ["bold", "italic", "underline", "strike", "blockquote"],
              [{ color: [] }, { 'background': [] }],
              [
                { list: "ordered" },
                { list: "bullet" },
                { indent: "-1" },
                { indent: "+1" },
              ],
              ['link'],
              ['image', 'video'],
              ['clean']
            ],
          },
        }), []);
    
      return (
        <QuillWrapper>
          <ReactQuill
            ref={(element) => {
              if (element !== null) {
                quillRef.current = element;
              }
            }}
            // onChange={}
            modules={modules}
            theme="snow"
            style={{ height: '100%', marginBottom: '0' }}
            placeholder={'Write Something...'}
          />
        </QuillWrapper>
      )
    })
    
    export default QuillEditor;

    에디터 툴바에 넣고싶은 기능(폰트 종류, 폰트 크기, 굵기, 기울임 등)을 정의하고 그 외 각종 에디터 테마, 크기 등을 지정합니다.

     

    만약 툴바와 텍스트 입력란을 분리시키고 싶다면 커스텀 툴바를 사용하면 됩니다.

     

    그와 관련하여 CodeSandbox에 정말 많은 예시들이 있습니다.

     

    react-playground - CodeSandbox

    react-playground using @emotion/css, @slate-editor/bold-plugin, @testing-library/jest-dom, @testing-library/react, @testing-library/user-event, emotion, image-extensions, inline-css, interweave

    codesandbox.io

     

    에디터 스타일링을 마쳤으면 ReactQuill 요소에 접근할 ref를 생성해줍니다.

    const quillRef = useRef<ReactQuill>();

     

    ref는 current 프로퍼티를 가진 객체 하나입니다.

     

    아래 코드와 같이 ref 객체를 특정 element(현재는 <ReactQuill>)의 ref 속성에 전달하면, current 프로퍼티로 해당 element DOM에 접근할 수 있게 됩니다.

    return (
        <QuillWrapper>
          <ReactQuill
            ref={quillRef}
            ...
          />
        </QuillWrapper>
      )

     

    그런데 <ReactQuill> 태그에 ref 속성을 부여했더니 다음과 같은 에러가 발생했습니다.

    'MutableRefObject<ReactQuill | undefined>' 형식은 'RefObject<ReactQuill>' 형식에 할당할 수 없습니다.
    'current' 속성의 형식이 호환되지 않습니다. 'ReactQuill | undefined' 형식은 'ReactQuill | null' 형식에 할당할 수 없습니다. 'undefined' 형식은 'ReactQuill | null' 형식에 할당할 수 없습니다. ts(2322)

     

    current를 콘솔에 출력한 결과 아래와 같이 undefined이기 때문이었습니다.

     

    current 출력 결과: undefined

    그 이유는 다음과 같습니다.

     

    타입스크립트의 경우, 타입스크립트가 컴파일 되는 시점에는 React Element DOM이 형성되지 않습니다. 타입스크립트가 컴파일 되는 시점=요소가 렌더링 되기 전이기 때문에, 타입스크립트 컴파일러는 특정한 DOM 요소가 존재한다고 확신할 수 없습니다.

     

    개발자 입장에서야 당연히 브라우저에서 실행시킬 것이라고 알고있지만 컴퓨터의 경우 Node.js 등 브라우저가 아닌 런타임 환경에서 실행될지 정확히 모릅니다. 따라서 타입스크립트가 컴파일 되는 시점에 특정 DOM에 대한 타입 추론은 제대로 동작할 수 없습니다.

     

    이런 경우 제네릭 타입으로 명시한 useRef<ReactQill>()<any>로 바꿔줄 수도 있겠지만, 이런 포괄적인 타입 명시는 타입스크립트를 쓰는 이유를 퇴색시키기 때문에 다른 방법을 이용하겠습니다.

     

     element가 null이 아니라는 조건문을 달아서, element가 존재하면 quillRef.current의 값을 해당 element로 지정하는 것입니다.

    return (
        <QuillWrapper>
          <ReactQuill
            ref={(element) => {
              if (element !== null) {
                quillRef.current = element;
              }
            }}
            ...
          />
        </QuillWrapper>
      )

     

     

    작성 글 확인하기

    포트폴리오 등록/수정 페이지에서 Quill 에디터를 통해 글을 작성하고 체크 버튼을 클릭하면, 제목/카테고리/태그 등을 입력하는 입력 폼이 나타납니다.(티스토리와 비슷)

     

    따라서 본문 데이터에 추가 데이터를 받고, 최종적으로 POST 요청을 해야합니다.

     

    깊게 생각해봐야 하는 부분은 Submit 요청이 일어나는 위치입니다.

     

    모든 input 필드와 Submit 버튼이 PortfolioEdit.tsx 파일에 들어있으면 form 태그에 넣어 곧바로 POST 요청을 보낼 수 있습니다.

     

    하지만 저는 제목을 입력 폼(TitleForm.tsx)이 별도로 나뉘어있고, 최종 Submit 버튼은 TitleForm.tsx에 있습니다. 따라서 TitleForm.tsx 컴포넌트에 에디터에서 작성한 내용이 전달돼야 합니다.

     

    PortfolioEdit.tsx 파일은 다음과 같습니다.

    export default function PortfolioEdit() {
      const [openTitle, setOpenTitle] = useState(false);
    
      return (
        <FlexColumnContainer gap={0} className="mx-h-screen top-0 overflow-hidden">
          <LogoHeader />
          <QuillEditor />
          {openTitle ? <TitleForm isCreated='' setOpenTitle={setOpenTitle} /> : null}
          <PortfolioEditButton
            type="black"
            className="absolute bottom-10 right-16" onClick={() => setOpenTitle(true)}>
            <BsCheck2 size="25" color="white" />
          </PortfolioEditButton>
        </FlexColumnContainer>
      );
    }
    • <QuillEditor /> - ReactQuill 에디터 컴포넌트
    • <TitleForm /> - 제목, 카테고리, 태그, 설명 입력 폼
    • <PortfolioEditButton /> - 검정색 체크 버튼

     

    화면에 그린 모습입니다.

     

    포트폴리오 등록/수정 페이지 UI

    지금부터 react-hook-form 과 react-quill 에디터를 적절히 섞어 폼을 제출해보겠습니다.

     

    처음에는 아래와 같이 react-hook-form의 register를 props로 전달하여 등록한 뒤 데이터 값을 가져오려 했습니다.

    return (
        <FlexColumnContainer gap={0} className="mx-h-screen top-0 overflow-hidden">
          <LogoHeader />
          
          // Quill 에디터
          <QuillEditor register={register}/>
          
          // TitleForm 열고 닫기
          {openTitle &&
          	<TitleForm isCreated='' setOpenTitle={setOpenTitle} register={register}/>}
          
          // Quill 에디터 우측 하단 체크 버튼 => 클릭 시 openTitle  true되고 TitleForm 나타남
          <PortfolioEditButton
            type="black"
            className="absolute bottom-10 right-16" onClick={() => setOpenTitle(true)}>
            <BsCheck2 size="25" color="white" />
          </PortfolioEditButton>
          
        </FlexColumnContainer>
      );

     

    <ReactQuill> 컴포넌트가 props로 받을 register 타입도 지정해주고,

    import { ComponentPropsWithoutRef } from 'react';
    import { FieldValues } from 'react-hook-form/dist/types/fields';
    import { UseFormRegister } from 'react-hook-form/dist/types/form';
    
    export interface QuillPropsType {
      register: UseFormRegister<FieldValues>;
    }

     

    <ReactQuill> 컴포넌트에서 다음과 같이 레지스터를 등록했습니다. 에디터 작성 내용인 htmlContent를 연결한 모습입니다.

    return (
        <QuillWrapper>
          <ReactQuill
            {...register("htmlContent")} // 여기
            // value={}
            // onChange={}
            modules={modules}
            theme="snow"
            style={{ height: '100%', marginBottom: '0' }}
            placeholder={'Writing Something...'}
          />
        </QuillWrapper>
      )

     

    그러나 register 함수가 반환하는 모든 메서드들의 타입이 컴포넌트에 존재하지 않았고, 다음와 같은 에러가 출력됐습니다.

    'onBlur' 속성의 형식이 호환되지 않습니다. 'ChangeHandler' 형식은 '(previousSelection: Range, source: Sources, editor: UnprivilegedEditor) => void' 형식에 할당할 수 없습니다. 'event' 및 'previousSelection' 매개 변수의 형식이 호환되지 않습니다. 'Range' 형식은 '{ target: any; type?: any; }' 형식에 할당할 수 없습니다. 'null' 형식은 '{ target: any; type?: any; }' 형식에 할당할 수 없습니다. ts(2322)

     

     

    그래서 <ReactQuill> 컴포넌트의 기본 속성인 value와 onChange를 적절히 조합하여 사용해야겠다고 판단했습니다.

     

    PortfolioEdit.tsx에서 useState으로 <ReactQuill>에서 작성한 데이터(htmlContent)를 관리하고,

    해당 상태를 <TitleForm> 컴포넌트에 전달하여 <TitleForm> 안에서 register 값으로 등록하는 것입니다.

     

    register를 input 필드에 등록하여 추적하는 게 아닌, htmlContent 값을 register에 직접 넣어주는 것입니다.

    register의 setValue라는 함수를 사용하면 충분히 가능한 일이었습니다.

     

    일단 다음과 같이 PortfolioEdit.tsx 에서 htmlContent 를 useState로 관리합니다. ReactQuill이 존재하는 <QuillEditor>에 props로 htmlContent와 그에따른 setState 함수를 넘겨줍니다.

    export default function PortfolioEdit() {
      const [openTitle, setOpenTitle] = useState(false);
      const [htmlContent, setHtmlContent] = useState<string>(''); // htmlContent를 상태로 관리
    
      return (
        <FlexColumnContainer gap={0} className="mx-h-screen top-0 overflow-hidden">
          <LogoHeader />
          
          // 여기서 htmlContent를 받아옴
          <QuillEditor htmlContent={htmlContent} setContentHandler={setHtmlContent} />
          
          // TitleForm에 htmlContent를 props로 넘겨줌
          {openTitle && 
          	<TitleForm isCreated='' setOpenTitle={setOpenTitle} htmlContent={htmlContent} />}
          
          <PortfolioEditButton
            type="black"
            className="absolute bottom-10 right-16" onClick={() => setOpenTitle(true)}>
            <BsCheck2 size="25" color="white" />
          </PortfolioEditButton>
          
        </FlexColumnContainer>
      );
    }

     

    그 다음 QuillEditor.tsx 에서 <ReactQuill>에 value와 onChange 속성을 다음과 같이 등록해주면, 에디터에 글자를 입력할 때마다 htmlContent 값이 업데이트 됩니다. (onChange 이벤트 속성의 첫 번째 인자로 Editor에 작성한 내용을 바로 접근할 수 있습니다)

    return (
        <QuillWrapper>
          <ReactQuill
            ref={(element) => {
              if (element !== null) {
                quillRef.current = element;
              }
            }}
            
            // 여기!!!
            value={htmlContent}
            onChange={(content) => setContentHandler(content)}
            
            modules={modules}
            theme="snow"
            style={{ height: '100%', marginBottom: '0' }}
            placeholder={'Writing Something...'}
          />
        </QuillWrapper>
      )

     

    이 htmlContent 값을 <TitleForm>에 props로 전달하고 콘솔에 출력해보니 다음과 같이 값을 잘 받았습니다.

     

    TitleForm이 받은 htmlContent 콘솔에 출력한 결과

     

     

    이제 react-hook-form 과 결합하여 입력받은 값을 제출하면 됩니다.

     

    <TitleForm>에 최종 제출 버튼이 있기 때문에, TitleForm.tsx 에서 react-hook-form 훅을 불러옵니다.

    import { SubmitHandler, useForm, FieldValues } from 'react-hook-form';

     

    그리고 props로 htmlContent를 받습니다. useForm() 훅을 통해 필요한 register, handleSubmit, setValue도 가져옵니다. 

    또한 중복제출 방지를 위한 상태값인 isSubmitting도 받아줍니다.

    const TitleForm = ({ isCreated, setOpenTitle, htmlContent }: TitleFormProps) => {
      const { register, handleSubmit, setValue, formState: { isSubmitting } } = useForm();
      ...
    }

     

    가장 먼저 htmlContent를 register에 값으로 저장해야 합니다. register의 두 번째 인자는 validate를 설정하는 객체입니다. 본문 최소 길이를 50자로 하며 조건이 충족되지 않을 시 form 자체가 Submit 되지 않게 설정했습니다.

    useEffect(() => {
        register("htmlContent", { required: true, minLength: 50 });
        setValue("htmlContent", htmlContent);
      }, [register]);

     

    그리고 Title 입력란에도 register를 등록시켜준 뒤 validate 옵션을 정의합니다. 마지막으로 제출 버튼 컴포넌트의 onClick 함수에 handleSubmit으로 감싼 핸들러를 넣어주면 됩니다.

    const onSubmitPortfolio: SubmitHandler<FieldValues> = (data) => {
        console.log(data.title + '\n' + data.htmlContent);
      };
      
      return (
        <TitleFormContainer>
          ...
            <PortfolioTitleInput placeholder='Title' {...register("title", { required: true, minLength: 5 })} />
            ...
            <PortfolioEditButton color='light' onClick={handleSubmit(onSubmitPortfolio)} disabled={isSubmitting}>
              <BsCheck2 size='25' color='black' />
            </PortfolioEditButton>
          ...
        </TitleFormContainer>
      )

     

    이제 게시물을 작성하고 Submit 버튼을 누르면 콘솔에 다음과 같이 정상적으로 출력되는 걸 확인할 수 있습니다.

     

    출력 결과

     

     

     

     

    참고 사이트

     

    https://codesandbox.io/p/sandbox/quill-with-react-hook-form-validation-cdjru

     

    codesandbox.io

     

    React-Quill typeScript 적용

    Quill? Quill은 최신 웹을 위해 만들어진 무료 오픈 소스 WYSIWYG 편집기로 모듈식 아키텍처와 표현식 API를 통해 필요에 따라 완벽하게 사용자 정의할 수 있다. 여러 편집기들이 있고 그 중 가장 대표

    isstar.tistory.com

Designed by Tistory.