TinyMCE 에디터 적용하기

손영산·2024년 1월 16일
0
post-custom-banner

배경

  • 서비스 중인 웹 페이지에 공지사항과 같은 글들을 등록하기 위한 어드민 전용 에디터 기능 요청
  • 처음 퀼에디터를 사용해 적용했으나 어드민 실 사용자의 요구사항을 만족하는 플러그인 부재 ( ex. 사이즈 직접 입력을 통한 이미지 리사이징 )
  • 요구사항들을 만족하는 에디터를 찾다보니 Tiny 에디터 발견

과정

  • 에디터 커스텀 이미지 핸들러 등록
  • 에디터 컨텐츠를 뷰어로 보여줄 때 CSS 기본 속성이 적용되어야 하는 부분이 많아 iframe 으로 적용

패키지 설치

npm i @tinymce/tinymce-react

Tiny Editor

import React from 'react';
import { useFormContext } from 'react-hook-form';

import newsApi from '@apis/news/newsApi';

import { CONFIG } from '@config';
import { Editor } from '@tinymce/tinymce-react';

import { BlobInfo } from './editor.type';

function TinyEditor() {
  const editorRef = React.useRef<Editor | null>(null);
  const methods = useFormContext();

  const handleImageUpload = async (blobInfo: BlobInfo) => {
    const formData = new FormData();
    const blob = await blobInfo.blob();
    formData.append('image', blob, blobInfo.filename());

    try {
      console.log({ formData });
      const data = await newsApi.uploadNewsImage(formData);
      console.log('성공 시, 백엔드가 보내주는 데이터', data);
      const IMG_URL = data.profileImageUrl;

      return IMG_URL;
    } catch (error) {
      console.log(error);
      console.log('실패했어요ㅠ');

      return '';
    }
  };

  return (
    <Editor
      ref={editorRef}
      apiKey={CONFIG.EDITOR_KEY}
      value={methods.watch('content')}
      init={{
        placeholder: '내용을 입력해 주세요...',
        plugins:
          'print preview paste importcss searchreplace autolink autosave save directionality code visualblocks visualchars fullscreen image link media template codesample table charmap hr pagebreak nonbreaking anchor toc insertdatetime advlist lists wordcount imagetools textpattern noneditable help charmap quickbars emoticons ',
        menubar: 'none',
        toolbar_sticky: true,
        toolbar1: 'undo redo removeformat | image link | fullscreen  preview',
        toolbar2:
          'h1 h2 h3 | fontsize fontfamily | bold italic underline strikethrough | forecolor backcolor | charmap emoticons',
        toolbar3:
          'alignleft aligncenter alignright alignjustify | outdent indent | numlist bullist',
        autosave_ask_before_unload: true,
        autosave_interval: '30s',
        autosave_prefix: '{path}{query}-{id}-',
        autosave_restore_when_empty: false,
        autosave_retention: '2m',
        importcss_append: true,
        quickbars_selection_toolbar:
          'bold italic | quicklink h1 h2 h3 blockquote quickimage quicktable',
        noneditable_noneditable_class: 'mceNonEditable',
        toolbar_mode: 'sliding',
        contextmenu: 'link image imagetools table',
        content_style:
          'body { font-family:Helvetica,Arial,sans-serif; font-size:14px }',
        width: '100%',
        height: 600,
        images_upload_handler: handleImageUpload,
      }}
      onEditorChange={(content) => {
        methods.setValue('content', content);
      }}
    />
  );
}

export default TinyEditor;
  • onEditorChange : 에디터에 입력된 컨텐츠 내용을 감지하는 핸들러
  • images_upload_handler : 이미지 업로드 핸들러 (이미지 경로를 반환해야 이미지가 에디터 내부에 그려짐)
    • 파일 등록 ⇒ 클라우드 저장소 저장 ⇒ 저장된 url 주소 반환

IFrameViewer

import { useEffect, useRef } from 'react';

interface Props {
  html: string;
}

const IFrameViewer = ({ html }: Props) => {
  const iframeRef = useRef<HTMLIFrameElement>(null);

  const createIframe = () => {
    const iframeDocument = iframeRef.current?.contentDocument;
    if (!iframeDocument) return;
    const blockquoteElements = iframeDocument.querySelectorAll('blockquote');

		// 1️⃣ iframe 내부 bockquote 요소 스타일 지정
    blockquoteElements.forEach((blockquoteElement) => {
      blockquoteElement.style.borderLeft = '2px solid #ccc';
      blockquoteElement.style.marginLeft = '1.5rem';
      blockquoteElement.style.paddingLeft = '1rem';
    });
		// 2️⃣ iframe 높이 동적 설정
    const contentHeight = iframeDocument.documentElement.scrollHeight;

    iframeRef.current.style.height = `${contentHeight}px`;
  };

	// 모든 이미지가 로드되었는지 확인
  const trackAllImageLoad = () => {
    const iframeDocument = iframeRef.current?.contentDocument;
    if (!iframeDocument) return;
    const imgElements = iframeDocument.querySelectorAll('img');

    const trackImageLoad = () => {
      let allImagesLoaded = true;

      imgElements.forEach((imgElement) => {
        if (!imgElement.complete) {
          allImagesLoaded = false;
        }
      });

      if (allImagesLoaded) {
        createIframe();
      } else {
        setTimeout(trackImageLoad, 100);
      }
    };

    trackImageLoad();
  };

  if (!html) {
    return null;
  }

  return (
    <iframe
      ref={iframeRef}
      srcDoc={html}
      onLoad={trackAllImageLoad}
      style={{
        width: '100%',
      }}
    />
  );
};

export default IFrameViewer;
  • 1️⃣ iframe 내부 html 요소의 스타일 지정을 위해 css 파일을 head에 추가했으나 적용되지 않아 iframe 돔 요소에 직접 접근하여 style 지정
  • 2️⃣ iframe의 높이를 동적으로 설정하기 위해서 iframe의 스크롤 높이(컨텐츠 길이)를 iframe의 높이로 설정
post-custom-banner

0개의 댓글