Timpossible history

[React JS] React-quill로 WYSIWYG 구현하는 중에 format과 blot 커스터마이징 본문

프론트엔드/React JS

[React JS] React-quill로 WYSIWYG 구현하는 중에 format과 blot 커스터마이징

팀파서블 2023. 12. 30. 14:37
  1. React-quill로 Text editor 구현 중 기존 module을 이용 시 느꼈던 한계
  2. 그에 따른 해결책(Quill.import 메소드 사용)
  3. 응용 사례
  4. 주의 사항

1.  React-quill로 Text editor 구현 중 기존 module을 이용 시 느꼈던 한계

사진이나 동영상 파일을 업로드하려 할 때 파일 선택 창이 뜨지 않았음

이를 해결하기 위해 image icon 클릭 시 실행되는 코드 구현

 

const formats = [
  "font",
  "size",
  "bold",
  "italic",
  "underline",
  "strike",
  "align",
  "blockquote",
  "list",
  "bullet",
  "indent",
  "background",
  "color",
  "link",
  "image",
  "width",
  "classify",
  "video",
];

const EditorForm = ({}) => {
	const quillRef = useRef(null);
    const [text, setText] = useState("");

    const handleImage = useCallback(() => {
        const _editor = quillRef.current.getEditor();
        // file type의 input을 생성 후 실행
        const imageInput = document.createElement("input");
        imageInput.setAttribute("type", "file");
        imageInput.setAttribute("accept", "image/*");
        imageInput.setAttribute("multiple", "");
        imageInput.click();
        imageInput.onchange = async () => {
          const imageFiles = imageInput.files;

          const formData = new FormData();
          Array.from(imageFiles).forEach((file) => {
            formData.append("image", file);
          });

          const range = _editor.getSelection(true);

          try {
          // 이미지들을 업로드 하고 저장된 url을 불러옴
            const dataList = await uploadImage(formData);

            dataList.files.forEach((item) => {
            // inserEmbed 메소드로 editor에 렌더링해줌
              _editor.insertEmbed(
                range.index,
                "image", // react-quill의 image 모듈을 사용
                item.location
              );
            });
            _editor.setSelection(range.index + 1); // 커서를 삽입된 이미지 옆에 위치시킴.
          } catch (e) {
            onAlert("이미지 업로드를 실패했습니다. 다시 시도하여 주세요");
          }
        };
      }, []);

  // video 파일 업로드도 같은 방식으로 실행
      const handleVideo = useCallback(() => {
        const _editor = quillRef.current.getEditor();

        const videoInput = document.createElement("input");
        videoInput.setAttribute("type", "file");
        videoInput.setAttribute("accept", "video/*");
        videoInput.setAttribute("multiple", "");
        videoInput.click();
        videoInput.onchange = async () => {
          const videoFiles = videoInput.files;

          const formData = new FormData();
          Array.from(videoFiles).forEach((file) => {
            formData.append("video", file);
          });

          const range = _editor.getSelection(true);

          try {
            const dataList = await uploadVideo(formData);

            dataList.forEach((item) => {
              _editor.insertEmbed(range.index, "customVideo", // 후에 기술할 커스터마이징한 새로운 format
               item.location
              );
            });

            _editor.setSelection(range.index + 1);
          } catch (e) {
            onAlert("비디오 업로드를 실패했습니다. 다시 시도하여 주세요");
          }

        };
      }, []);

  const modules = {
    toolbar: {
      container: "#toolbar",
      handlers: {
        video: handleVideo, // 기존 reactquill video 모듈에 handleVideo 함수 연결
        image: handleImage, // 기존 reactquill image 모듈에 handleImage 함수 연결
      },
    },
    imageResize: {
      parchment: Quill.import("parchment"),
      modules: ["Resize", "DisplaySize"],
    },
  };

return (
      <ReactQuill
        className={styles.editor}
        ref={quillRef}
        value={text}
        theme="snow"
        modules={modules}
        formats={formats}
        onChange={(content, delta, source, editor) => {
          setText(content);
        }}
        preserveWhitespace
      />

  );
};

◆ 한계

- ReactQuill 컴포넌트에 렌더링되는 이미지와 비디오의 크기 및 정렬 상태가 파일의 자체 사이즈에 따라서 판이하게 달라져서 보기가 불편하고 지저분해 보임.

- 렌더링되는 DOM을 style 변경이 가능하도록 커스터마이징할 필요가 있었음

 


 

2.  그에 따른 해결책(Quill.import 메소드 사용)

React-quill은 format과 blots을 import해서 내가 원하는대로 Embed 삽입이 가능함.

import ReactQuill, { Quill } from "react-quill";

// 기존 formats/video 호출이 아닌 blots/block/embed 호출
const Video = Quill.import("blots/block/embed");
const Image = Quill.import("formats/image");

class CustomVideo extends Video {
  static create(value) {
    const node = document.createElement("div");
    node.setAttribute("style", "width:100%;");

    const container = document.createElement("div");
    container.setAttribute("class", "cv_container");
    container.setAttribute(
      "style",
      "background-color:black; position:relative; padding-bottom:56.25%; padding-top:30px; height:0; overflow:hidden; width:100%; max-width:500px; margin: 0 auto;"
    );

    const video = document.createElement("video");
    video.setAttribute("class", "cv_video");
    video.setAttribute("src", value.url);
    video.setAttribute("controls", "true");
    video.setAttribute(
      "style",
      "position:absolute; top:0; left:0; width: 100%; height: 100%; margin: 0 auto;"
    );
    video.setAttribute("type", `video/${getExt(value.url)}`);

    container.appendChild(video);
    node.appendChild(container);
    return node;
  }

  static value(node) {
    return { url: node.getAttribute("src") };
  }
}

// 이 property들에 고유의 이름으로 데이터 값을 주고 등록해줘야 나중에 데이터를 다시 읽을 때 충돌이 안 일어남
CustomVideo.blotName = "customVideo";
CustomVideo.className = "ql-custom-video";
CustomVideo.tagName = "video";

// 새로운 blot 커스터마이징해서 Quill 인스턴스에 등록
Quill.register(CustomVideo);

class CustomImage extends Image {
  static create(value) {
    let node = super.create(value);
    node.setAttribute("style", "width: 100%; max-width: 550px;");
    let container = document.createElement("div");
    container.setAttribute("style", "text-align: center;");
    container.appendChild(node);
    return container;
  }
}

// 기존 image format을 불러와서 커스터마이징한 것이니 그대로 등록만 해주면 됨
Quill.register(CustomImage, true);

const formats = [
  "font",
  "size",
  "bold",
  "italic",
  "underline",
  "strike",
  "align",
  "blockquote",
  "list",
  "bullet",
  "indent",
  "background",
  "color",
  "link",
  "image", //CustomImage 클래스가 기존 image format에 연결됨
  "width",
  "classify",
  "video", // 기존 video format은 youtube 링크 삽입용으로 사용
  "customVideo", // 새로운 CustomVideo format 등록
];

const EditorForm = ({}) => {
	
    ....
    
 
return (
      <ReactQuill
        className={styles.editor}
        ref={quillRef}
        value={text}
        theme="snow"
        modules={modules}
        formats={formats}
        onChange={(content, delta, source, editor) => {
          setText(content);
        }}
        preserveWhitespace
      />

  );
};

 

Quill.import(formats/image)와 Quill.import(blots/block/embed)를 이용하여 새로운 형태의 DOM을 정의하고 스타일링을 추가해주니 내가 원한대로 렌더링이 됨.

 


 

3. 응용 사례

이뿐 아니라 Quill.import(blots/embed)를 사용하면 더 Rich한 DOM 생성이 가능함.

  • blots/block/embed : 동영상 같은 블록 요소들을 커스터마이징할 때 용이함.
  • blots/embed : 링크나 이미지 같은 인라인 요소들을 커스터마이징할 때 용이함.

필자의 사례로는 Text editor에 링크를 입력하거나 붙여넣기할 때 사이트의 메타데이터를 읽어서 렌더링하는데에 사용함.

const Embed = Quill.import("blots/embed");

class LinkPreviewBlot extends Embed {
  static create(value) {
    let node = super.create();

    node.setAttribute(
      "style",
      `width: 100%; max-width:600px; border: 1px solid lightgrey; border-radius: 5px; -webkit-box-shadow: 2px 2px 6px 0px #000000; box-shadow: 2px 2px 6px 0px #000000;`
    );

    const link = document.createElement("a");
    link.setAttribute("href", value.link);

    link.setAttribute("target", "_blank");
    link.setAttribute("rel", "noopener noreferrer");
    link.setAttribute("style", "display: flex; text-decoration: none;");

    const imgContainer = document.createElement("div");
    link.setAttribute("class", "imgContainer");
    imgContainer.setAttribute(
      "style",
      "flex: 1 1 20%; padding: 0 4px; border-right: 1px solid lightgrey;"
    );

    const img = document.createElement("img");
    img.setAttribute("class", "preview_img");
    img.setAttribute("src", value.image);
    img.setAttribute("style", "width:100%; height: 100%; border-radius: 5px;");
    imgContainer.appendChild(img);

    const descContainer = document.createElement("div");
    descContainer.setAttribute("class", "descContainer");
    descContainer.setAttribute(
      "style",
      "display: flex; flex-direction: column; justify-content: center; flex: 1 1 80%; padding: 4px;"
    );

    const siteName = document.createElement("p");
    siteName.innerText = value.siteName;
    siteName.setAttribute("class", "preview_siteName");
    siteName.setAttribute(
      "style",
      "font-weight: bold; color: black; padding:0 0 2px 0; margin: 0; font-size: 15px;"
    );
    descContainer.appendChild(siteName);

    const title = document.createElement("p");
    title.innerText = value.title;
    title.setAttribute("class", "preview_title");
    title.setAttribute(
      "style",
      "color: black; padding:2px 0 2px 0; margin: 0; font-size: 15px;"
    );
    descContainer.appendChild(title);

    const siteUrl = document.createElement("p");
    siteUrl.innerText = value.siteUrl;
    siteUrl.setAttribute("class", "preview_siteUrl");
    siteUrl.setAttribute(
      "style",
      "font-weight: bold; color: grey; padding: 2px 0 0 0; margin: 0; font-size: 15px;"
    );
    descContainer.appendChild(siteUrl);

    link.appendChild(imgContainer);
    link.appendChild(descContainer);

    node.appendChild(link);

    return node;
  }

  static value(node) {
    return {
      link: node.querySelector(".imgContainer")?.getAttribute("href"),
      image: node.querySelector(".preview_img")?.getAttribute("src"),
      title: node.querySelector(".preview_title")?.innerText,
      siteUrl: node.querySelector(".preview_siteUrl")?.innerText,
      siteName: node.querySelector(".preview_siteName")?.innerText,
    };
  }
}
LinkPreviewBlot.blotName = "customBlock";
LinkPreviewBlot.tagName = "div";
LinkPreviewBlot.className = "link-preview";

Quill.register(LinkPreviewBlot, true);


const formats = [
 ...,
 "customBlock"
 ]

4. 주의 사항

Quill의 여러 format들을 import해서 커스터마이징한 후 등록하고 그 컴포넌트를 생성하다보니 커스터마이징한 format들의 property들을 명확하게 설정해주지 않으면 혼동이 일어나서 후에 저장한 데이터들을 다시 읽어올 때 영향을 주게 됨.

  • blotName
  • className
  • tagName

이 세 가지는 명확하게 설정해줘야 함.