tui-editor 서버 이미지 업로드, youtube embed 기능 추가하기

최원빈·2022년 10월 18일
4

TOAST UI의 에디터는 마크다운과 위지윅 에디터를 제공하고, 에디터로 작성한 글을 그대로 볼 수 있게 뷰어도 제공한다.

개인적으로 위지윅 라이브러리계의 양대산맥이라고 생각하는 Quill Editor와 비교하면 마크다운 에디터를 제공한다는 점에서 경쟁력이 있다고 생각한다.

단점은 기본적으로 유튜브 영상 임베디드 기능이 없다는 점과, 이미지 삽입 시 생기는 방대한 양의 텍스트를 그대로 보일 수밖에 없다는 점이다.

어우야

사실 이미지를 서버 전달 없이 위지윅에 띄우려면 저 방법밖에 없지만서도, 아쉬운건 아쉬운 것 같다.
반드시 커스텀을 거쳐야 한다


기본 Editor 세팅

TOAST 팀은 React에서 tui-editor를 사용하기 편하게 컴포넌트화 시켜두어서, 이를 활용했다.

import { Editor } from '@toast-ui/react-editor';

export default function MarkdownEditor({initialValue}) {
  const editorRef = useRef<Editor>(null);

  return (
    <>
      <Editor
        previewStyle="vertical"
        height="700px"
        ref={editorRef}
        initialValue={initialValue}
      />
    </>
  );
}

Editor의 레퍼런스는 getInstance()메소드를 제공하고, 이를 통해 에디터에 접근할 수 있다.


Editor의 레퍼런스는 getInstance()메소드를 제공하고, 이를 통해 에디터에 접근할 수 있다.

웹에서 로컬 이미지를 사용할 땐, 서버에 업로드한 뒤 해당 위치의 url을 받아서 사용한다.
로컬 이미지를 그대로 사용하다가 위에 꼴 난다.

직접 만든다면 <input type="file"> 을 쓰겠지만, 팝업을 구현해둔 툴바를 기본적으로 제공하니 이를 변경해서 사용하자.

기본 제공 insert image 툴바를 활용하자.

기본적으로 Editor는 이미지 업로드에 addImageBlobHook을 실행하니, 이를 커스텀하는 방법을 선택했다.

import { Editor } from '@toast-ui/react-editor';

export default function MarkdownEditor({initialValue}) {
  const editorRef = useRef<Editor>(null);
  
  useEffect(() => {
    // 기존 Hook 제거
    editorRef.current?.getInstance().removeHook('addImageBlobHook');
    
    // 새로운 addImageBlobHook 추가
    editorRef.current?.getInstance().addHook('addImageBlobHook', async (file) => {
      const formData = new FormData();
      formData.append('multipartFile', file);

      await axios({
        method: 'post',
        url: '/file?fileType=IMAGE', // 서버 파일업로드 API url
        data: formData,
        headers: {
          'Content-Type': 'multipart/form-data',
        },
      })
        .then(({ data }) => {
          // insertText 메소드로 커서가 가리키던 곳에 텍스트를 추가할 수 있다.
          editorRef.current?.getInstance().insertText(`<img src="${data.url}" alt=""/>`);
        })
    });
  }, [])
  
  return (
    <>
      <Editor
        previewStyle="vertical"
        height="700px"
        ref={editorRef}
        initialValue={initialValue}
      />
    </>
  );
}

첨부는 잘 된다.

그런데 이미지를 추가에 성공했는데 팝업이 닫히지 않는다.
찾아보니 같은 이슈를 마주친 사람들이 있었고, 개발진측에서 빠르게 기능을 추가해주었다.


closePopup 이벤트를 발생시킨다.

이미지는 완전하게 성공!


Youtube Embed 추가

일단 유튜브 로고를 추가하고, 해당 툴바 자리에 끼워넣어야한다.
커스텀 툴바 플러그인을 만들어서 넣기엔 찾아볼 시간과 노력이 부족했다.

import { Editor } from '@toast-ui/react-editor';
import youtubeLogo from '../../assets/images/youtube-icon.png';

export default function MarkdownEditor({initialValue}) {
  const editorRef = useRef<Editor>(null);
  
  useEffect(() => {
    editorRef.current?.getInstance().insertToolbarItem(
      { groupIndex: 3, itemIndex: 3 },
      {
        name: 'youtube',
        tooltip: 'youtube',
        className: 'toastui-editor-toolbar-icons',
        style: { backgroundImage: `url(${youtubeLogo})`, backgroundSize: '25px', color: 'red' },
      }
    );
    
    editorRef.current?.getInstance().removeHook('addImageBlobHook');
    //addHook...
  }, [])
  
  return (
    <>
      <Editor
        previewStyle="vertical"
        height="700px"
        ref={editorRef}
        initialValue={initialValue}
      />
    </>
  );
}

그러고나면, 클릭되었을 때의 행동을 정의해야하는데, 타입스크립트의 타입 정의를 따라가다보니 command를 정의해 할 수 있다는 사실을 알았다.

export default function MarkdownEditor({initialValue}) {
  const editorRef = useRef<Editor>(null);
  
  useEffect(() => {
    // addCommand는 3번째 인자의 콜백함수로 반드시 저 4개의 인자를 받으며 성공 여부를 리턴하는 함수를 담아야 한다.
    editorRef.current?.getInstance().addCommand('markdown', 'addYoutube', (payload, state, dispatch, view) => {
      let url = prompt('추가할 youtube 영상의 주소창 url을 담아주세요!');
      
      // url을 담지 않거나, 취소했을경우 취소.
      if(!url) return false;
      url = url?.split('=').at(-1) ?? '';
      const str = `<iframe src="https://www.youtube.com/embed/${url}" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
      editorRef.current?.getInstance().insertText(str);
      return true;
    })
    
    editorRef.current?.getInstance().insertToolbarItem(
      { groupIndex: 3, itemIndex: 3 },
      {
        name: 'youtube',
        tooltip: 'youtube',
        className: 'toastui-editor-toolbar-icons',
        style: { backgroundImage: `url(${youtubeLogo})`, backgroundSize: '25px', color: 'red' },
        command: 'addYoutube'   // 트리거를 담으면 툴바아이템의 클릭이벤트에 맞춰진다.
      }
    );
    
    editorRef.current?.getInstance().removeHook('addImageBlobHook');
    //addHook...
  }, [])
  
}

에디터에서 영상 보이게 하기

TUI Editor는 iframe을 제대로 렌더링할 수 없어서, 설정을 추가해주어야한다.
type error가 나는 부분이 있어서 찾느라 애먹었다.

<Editor
  previewStyle="vertical"
  height="700px"
  ref={editorRef}
  initialValue={initialValue}
  onChange={editedValue}
  customHTMLRenderer={{
    htmlBlock: {
      iframe(node) {
        return [
          { type: 'openTag', tagName: 'iframe', outerNewLine: true, attributes: node.attrs },
          { type: 'html', content: node.childrenHTML ?? '' },  // 여기 반드시 string이어야 한다
          { type: 'closeTag', tagName: 'iframe', outerNewLine: true },
        ];
      },
    },
    htmlInline: {
      big(node, { entering }) {
        return entering
          ? { type: 'openTag', tagName: 'big', attributes: node.attrs }
          : { type: 'closeTag', tagName: 'big' };
        },
      },
    }}
/>

영상이 나오긴 한다.

이제 영상은 보이긴 하는데, 크기가 작다.
youtube embed가 좌우로 꽉 차고 height이 영상에 딱 맞게 만들어지길 원한다.
iframe { width:100% height: auto } 를 원한다.

근데 이걸 어디서 검색해봐도, 모두가 같은 방법을 알려주는데...

// css
.video-container {
  position: relative;
  padding-bottom: 56.25%; /* 16:9 */
  height: 0;
}
.video-container iframe {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}
// html
<div class="video-container">
    <iframe ...embed 속성></iframe>
</div>

이렇게 넣으랜다.
반드시 div로 감싸고 padding-bottom 56.25%라는 애매한 수치를 줘야만 저렇게 나온다.
별 걸 다 시도해봤지만 저렇게가 답이더라.

그래서 바꿨다.
div태그를 insert하던 url에 추가하고,
div태그도 iframe처럼 Editor에서 걸러주기에, customRenderer를 수정했다.
그리고 CSS값도 추가했다.

const EditorContainer = styled.div`
  .video-container {
    position: relative;
    padding-bottom: 56.25%;
    padding-top: 30px;
    height: 0;
    overflow: hidden;
  }
  .video-container iframe,
  .video-container object,
  .video-container embed {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    border: none;
  }
`;

export default function MarkdownEditor({ initialValue }) {
  const editorRef = useRef<Editor>(null);

  useEffect(() => {
    editorRef.current?.getInstance().addCommand('markdown', 'addYoutube', (payload, state, dispatch, view) => {
      let url = prompt('추가할 youtube 영상의 주소창 url을 담아주세요!');
      if(!url) return false;
      url = url?.split('=').at(-1) ?? '';
      const str = `
        <div class="video-container">
          <iframe src="https://www.youtube.com/embed/${url}" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
        </div>
      `;
      editorRef.current?.getInstance().insertText(str);
      return true;
    })
    
    editorRef.current?.getInstance().insertToolbarItem(
      { groupIndex: 3, itemIndex: 3 },
      {
        name: 'youtube',
        tooltip: 'youtube',
        className: 'toastui-editor-toolbar-icons',
        style: { backgroundImage: `url(${youtubeLogo})`, backgroundSize: '25px', color: 'red' },
        command: 'addYoutube'
      }
    );
    editorRef.current?.getInstance().removeHook('addImageBlobHook');
    editorRef.current?.getInstance().addHook('addImageBlobHook', async (file) => {
      const formData = new FormData();
      formData.append('multipartFile', file);

      await axios({
        method: 'post',
        url: '/file?fileType=IMAGE',
        data: formData,
        headers: {
          'Content-Type': 'multipart/form-data',
        },
      })
        .then(({ data }) => {
          editorRef.current?.getInstance().insertText(`<img src="${data.url}" alt=""/>`);
          editorRef.current?.getInstance().eventEmitter.emit('closePopup');
        })
        .catch((error) => {
          console.error(error);
        });
    });
  }, []);

  return (
    <EditorContainer>
      <Editor
        previewStyle="vertical"
        height="700px"
        ref={editorRef}
        initialValue={initialValue}
        customHTMLRenderer={{
          htmlBlock: {
            iframe(node) {
              return [
                {
                  type: 'openTag',
                  tagName: 'iframe',
                  outerNewLine: true,
                  attributes: node.attrs,
                },
                { type: 'html', content: node.childrenHTML ?? '' },
                { type: 'closeTag', tagName: 'iframe', outerNewLine: true },
              ];
            },
            div(node) {
              return [
                { type: 'openTag', tagName: 'div', outerNewLine: true, attributes: node.attrs },
                { type: 'html', content: node.childrenHTML ?? '' },
                { type: 'closeTag', tagName: 'div', outerNewLine: true },
              ];
            },
          },
          htmlInline: {
            big(node, { entering }) {
              return entering
                ? { type: 'openTag', tagName: 'big', attributes: node.attrs }
                : { type: 'closeTag', tagName: 'big' };
            },
          },
        }}
      />
    </EditorContainer>
  );
}

여담으로 위에 나오는 토스 슬래시 영상은 내용이 너무 좋다.
TUI 도 좋다. 영상 Embed 기능만 기본으로 나온다면 더 좋을 것 같다.
팝업까지 이쁘게 해서 만들어주세요

profile
FrontEnd Developer

1개의 댓글

comment-user-thumbnail
2023년 11월 16일

allowfullscreen 속성이 없어지는데 이유를 아시나요?

답글 달기