웹/프론트엔드

[Next.js] Next.js에서 Toast UI Editor 사용하기

이민훈 2022. 9. 20. 22:53

https://ui.toast.com/tui-editor

 

TOAST UI :: Make Your Web Delicious!

TOAST UI is an open-source JavaScript UI library maintained by NHN Cloud.

ui.toast.com

 

사내 백오피스 개발 중에 웹 에디터가 필요한 기능이 생겨, 에디터 라이브러리 중 사용해본 적 있던 Toast UI Editor를 다시 붙이게 되었다.

Toast UI Editor는 nhn에서 개발한 Editor인데, 아직까지는 SSR를 지원하지 않기 때문에,

Next.js에 적용하려면 클라이언트 사이드에서 에디터를 불러다 렌더링해야 한다.

해당 문제는 좋은 블로그 글들을 참조해 해결했지만, 그 외 겪은 다양한 문제점들도 같이 적어보려 한다.

 

npm, yarn 등 편한 패키지 관리 툴로 라이브러리를 다운받도록 하자.

npm i @toast-ui/react-editor

 

컴포넌트를 하나 생성하고, 

//components/TuiEditor/index.tsx
import { Editor } from "@toast-ui/react-editor";
import "@toast-ui/editor/dist/toastui-editor.css";

const TuiEditor = () => {
  return <Editor />;
};

export default TuiEditor;

 

불러다 써보면..

//pages/index.tsx
import TuiEditor from "../components/TuiEditor";
import type { NextPage } from "next";

const Home: NextPage = () => {
  return <TuiEditor />;
};

export default Home;

 

바로 터진다.

 

 

앞서 말했듯, Toast UI Editor는 아직 SSR을 지원하지 않고 있고, Next.js는 기본적으로 서버 사이드에서

각 페이지들을 pre-rendering하기 때문에 Toast UI Editor를 import할 수 없다.

 

더 정확히 하면 Toast UI Editor 내부적으로 서버 사이드에서 사용할 수 없는,

브라우저(클라이언트 사이드)에만 존재하는 객체(window, document 등이 해당함)를 참조하기 때문에 에러를 뱉는다.

 

컴포넌트를 하나 더 만들어, Editor를 반환하게끔 하고,

//components/TuiEditor/WrappedEditor.tsx
import { Editor } from "@toast-ui/react-editor";
import "@toast-ui/editor/dist/toastui-editor.css";

const WrappedEditor = () => {
  return <Editor />;
};

export default WrappedEditor;

 

기존의 TuiEditor 컴포넌트에서는 WrappedEditor 컴포넌트를 동적으로 부른다.

즉, 서버 사이드에서 미리 import 하지 않고, 런타임(브라우저)에서 해당 컴포넌트를 import 하겠다는 말이다.

그러면, 서버에서 참조할 수 없는 값들을 참조할 수 있게 된다.

 

절대로 해당 컴포넌트들은 pages 폴더 안에 두면 안 된다.

next.js에서 페이지를 제외한 파일들은 당연히 pages 안에 두면 안 되지만,

동적으로 부르는 컴포넌트를 pages폴더안에 둘 경우, 빌드 시점에 self is not defined 에러를 또 만나게 된다.

next.js는 모든 페이지를 pre-rendering하기 때문이다.

//components/TuiEditor/index.tsx
import "@toast-ui/editor/dist/toastui-editor.css";
import dynamic from "next/dynamic";

const WrappedEditor = dynamic(() => import("./WrappedEditor"), { ssr: false });

const TuiEditor = () => {
  return <WrappedEditor />;
};

export default TuiEditor;

 

동적으로 부른 컴포넌트에는 ref를 넘겨줘도 제대로 인식하지 않기 때문에, forwardRef를 통해 넘겨주면 된다.

아래는 typescript와 기본적인 핸들러를 작성한 코드다.

//components/Editor/WrappedEditor.tsx
import { Editor, EditorProps } from "@toast-ui/react-editor";
import "@toast-ui/editor/dist/toastui-editor.css";
import { ForwardedRef } from "react";

interface WrappedEditorProps {
  forwardedRef: ForwardedRef<Editor>;
}

const WrappedEditor = (props: EditorProps & WrappedEditorProps) => {
  return <Editor ref={props.forwardedRef} {...props} />;
};

export default WrappedEditor;

 

//components/Editor/index.tsx
import "@toast-ui/editor/dist/toastui-editor.css";
import { Editor, EditorProps } from "@toast-ui/react-editor";
import dynamic from "next/dynamic";
import {
  ForwardedRef,
  forwardRef,
  useCallback,
  useEffect,
  useRef,
} from "react";

const WrappedEditor = dynamic(() => import("./WrappedEditor"), { ssr: false });

const ForwardedEditor = forwardRef(
  (props: EditorProps, forwardedRef: ForwardedRef<Editor>) => {
    return <WrappedEditor {...props} forwardedRef={forwardedRef} />;
  }
);
ForwardedEditor.displayName = "ForwardedEditor";

interface TuiEditorProps {
  initialValue: string;
  onChange: (e: string) => void;
}

const TuiEditor = ({ initialValue, onChange }: TuiEditorProps) => {
  const ref = useRef<Editor>(null);

  // initialValue가 바뀌면 Editor의 내용을 바꿔준다.
  useEffect(() => {
    if (!ref.current) return;

    const instance = ref.current.getInstance();
    instance.setHTML(initialValue);
  }, [initialValue]);

  // Editor의 내용이 바뀔때 넘겨받은 핸들러에 내용을 넘겨준다.
  const handleChange = useCallback(() => {
    if (!ref.current) return;

    const instance = ref.current.getInstance();
    onChange(instance.getHTML());
  }, [onChange]);

  return (
    <ForwardedEditor
      ref={ref}
      initialValue={initialValue}
      onChange={handleChange}
    />
  );
};

export default TuiEditor;

 

//pages/index.tsx
import TuiEditor from "../components/TuiEditor";
import type { NextPage } from "next";

const Home: NextPage = () => {
  return (
    <TuiEditor initialValue="Hello World!" onChange={(e) => console.log(e)} />
  );
};

export default Home;

 

forwardRef에 대한 이해가 어렵다면, 굳이 한 번 더 계층을 두지 않고 Editor 컴포넌트를 만든 후

//components/TuiEditor/index.tsx
import "@toast-ui/editor/dist/toastui-editor.css";
import { Editor } from "@toast-ui/react-editor";
import { useCallback, useEffect, useRef } from "react";

interface TuiEditorProps {
  initialValue: string;
  onChange: (e: string) => void;
}

const TuiEditor = ({ initialValue, onChange }: TuiEditorProps) => {
  const ref = useRef<Editor>(null);

  // initialValue가 바뀌면 Editor의 내용을 바꿔준다.
  useEffect(() => {
    if (!ref.current) return;

    const instance = ref.current.getInstance();
    instance.setHTML(initialValue);
  }, [initialValue]);

  // Editor의 내용이 바뀔때 넘겨받은 핸들러에 내용을 넘겨준다.
  const handleChange = useCallback(() => {
    if (!ref.current) return;

    const instance = ref.current.getInstance();
    onChange(instance.getHTML());
  }, [onChange]);

  return <Editor ref={ref} onChange={handleChange} />;
};

export default TuiEditor;

 

아래처럼 불러다 쓰는 쪽에서 dynamic import를 해도 큰 문제는 없지만,

구조적으로 전자가 조금 더 좋아 보이긴 한다.

//pages/index.tsx
import type { NextPage } from "next";
import dynamic from "next/dynamic";

const TuiEditor = dynamic(() => import("../components/TuiEditor"), {
  ssr: false,
});

const Home: NextPage = () => {
  return (
    <TuiEditor initialValue="Hello World!" onChange={(e) => console.log(e)} />
  );
};

export default Home;

 

마지막으로, Tui Editor에서는 이미지를 삽입할 시 base64로 인코딩하여 결괏값을 반환한다.

그 결과 img 태그의 src 프로퍼티에 매우 긴 문자열이 들어가게 되고 최종적으로 추출할 html 파일의 용량도 커지게 된다.

 

 

해당 문제는 Tui Editor에서 지원해주는 addImageBlobHook을 사용하면 된다.

자세한 내용은 아래 링크 참조

 

https://nhn.github.io/tui.editor/latest/ToastUIEditorCore

 

https://nhn.github.io/tui.editor/latest/ToastUIEditorCore/

RETURNS: { Array. >Array. } - Returns the range of the selection depending on the editor mode

nhn.github.io

 

addImageBlobHook에 새로운 콜백을 정의한다면 2가지 인자(blob, callback)를 받아올 수 있다.

그럼 blob을 서버에 업로드하고 서버에서 받은 이미지 url을 콜백 안에 넣어주면 된다.

    <Editor
      hooks={{
        addImageBlobHook: (blob, callback) => {
          const formData = new FormData();
          formData.append("file", blob);

          const uploadInput: UploadParams = {
            formData: formData,
            successCallback: (res) => callback(res.data.upload_image_url, "image"),
          };

          upload.mutate(uploadInput);
        },
      }}
    />

 

이렇게 작성된 글을 아마도 S3 같은 스토리지 서버에 업로드 후 글을 조회할 때 html 파일을 내려받을 것이다.

이때 S3 서버를 사용하게 되면, CORS 에러가 발생하는데 해당 문제는 아래 포스팅을 참조하면 된다.

 

https://hackids.tistory.com/140

 

[S3] AWS S3 fetch시 cors문제 해결

텍스트 에디터를 달아 글 작성을 완료할 때 반환받은 HTML을 파일로 만들어 S3로 업로드했다. DB에는 당연히 S3에 업로드 후 반환받은 url을 집어넣는다. 그리고 해당 글을 불러올 때 해당 url로 fetch

hackids.tistory.com

 

 

 

 

 

현재 Tui Editor 3.2.0 버전에서 링크 삽입이 안 되는 이슈가 있습니다!

{
  "dependencies": {
    "@toast-ui/react-editor": "3.1.10",
  },
  "resolutions": {
    "@toast-ui/react-editor/@toast-ui/editor": "3.1.8"
  }
}

 

@toast-ui/react-editor 3.1.10 버전으로 맞춰주고

resoultion에 @toast-ui/react-editor/@toast-ui/edtior 3.1.8 버전으로 하위 모듈 버전을 맞춰주면

문제없이 동작합니다.

 

이슈 참고 : https://github.com/nhn/tui.editor/pull/2729

 

fix: link mark no longer building with misnamed getCustomAttrs (fix #2687, #2671, #2730) by jethrolarson · Pull Request #2729

Please check if the PR fulfills these requirements It's the right issue type on the title When resolving a specific issue, it's referenced in the PR's title (e.g. fix #xxx[,#xxx], where "xxx" is...

github.com